From a8be5a5a0caab08c3b3a18606808ec947dd9afe0 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:25:52 -0300 Subject: [PATCH] chore: Extensions architecture POC (#31934) Co-authored-by: Ville Brofeldt Co-authored-by: Ville Brofeldt Co-authored-by: Ville Brofeldt --- .github/CODEOWNERS | 10 + .github/actions/change-detector/action.yml | 19 +- .github/workflows/superset-app-cli.yml | 67 + .github/workflows/superset-cli.yml | 69 +- .gitignore | 2 +- .pre-commit-config.yaml | 80 +- .rat-excludes | 2 + Dockerfile | 9 + pyproject.toml | 9 +- requirements/base.txt | 10 +- requirements/development.in | 1 + requirements/development.txt | 322 +- scripts/change_detector.py | 5 + scripts/uv-pip-compile.sh | 10 +- superset-cli/LICENSE.txt | 216 ++ superset-cli/README.md | 22 + superset-cli/pyproject.toml | 97 + superset-cli/src/superset_cli/__init__.py | 16 + superset-cli/src/superset_cli/cli.py | 471 +++ superset-cli/src/superset_cli/constants.py | 19 + .../templates/backend/pyproject.toml.j2 | 4 + .../superset_cli/templates/extension.json.j2 | 25 + .../templates/frontend/package.json.j2 | 34 + superset-cli/src/superset_cli/utils.py | 42 + superset-cli/tests/README.md | 206 ++ superset-cli/tests/__init__.py | 16 + superset-cli/tests/conftest.py | 136 + superset-cli/tests/test_cli_build.py | 552 ++++ superset-cli/tests/test_cli_bundle.py | 255 ++ superset-cli/tests/test_cli_dev.py | 238 ++ superset-cli/tests/test_cli_init.py | 362 +++ superset-cli/tests/test_cli_validate.py | 195 ++ superset-cli/tests/test_templates.py | 329 ++ superset-cli/tests/test_utils.py | 271 ++ superset-cli/tests/utils.py | 211 ++ superset-core/.gitignore | 1 + superset-core/LICENSE.txt | 216 ++ superset-core/pyproject.toml | 42 + superset-core/src/superset_core/__init__.py | 16 + .../src/superset_core/api/__init__.py | 24 + .../src/superset_core/api/types/__init__.py | 16 + .../src/superset_core/api/types/models.py | 90 + .../src/superset_core/api/types/query.py | 41 + .../src/superset_core/api/types/rest_api.py | 64 + .../src/superset_core/extensions/__init__.py | 16 + .../src/superset_core/extensions/types.py | 63 + superset-frontend/babel.config.js | 2 + superset-frontend/jest.config.js | 2 + superset-frontend/package-lock.json | 2712 +++++++++++++++++ superset-frontend/package.json | 2 + .../packages/superset-core/.babelrc.json | 7 + .../packages/superset-core/package.json | 35 + .../superset-core/src/api/authentication.ts | 43 + .../superset-core/src/api/commands.ts | 70 + .../superset-core/src/api/contributions.ts | 90 + .../packages/superset-core/src/api/core.ts | 245 ++ .../superset-core/src/api/environment.ts | 153 + .../superset-core/src/api/extensions.ts | 69 + .../packages/superset-core/src/api/index.ts | 42 + .../packages/superset-core/src/api/sqlLab.ts | 420 +++ .../packages/superset-core/src/index.ts | 19 + .../packages/superset-core/tsconfig.json | 17 + .../superset-ui-chart-controls/src/index.ts | 4 +- .../src/components/Popconfirm/index.tsx | 26 + .../superset-ui-core/src/components/index.ts | 1 + .../src/connection/SupersetClient.ts | 1 + .../src/connection/SupersetClientClass.ts | 8 +- .../superset-ui-core/src/connection/types.ts | 1 + .../src/utils/featureFlags.ts | 1 + .../test/connection/SupersetClient.test.ts | 2 +- .../spec/helpers/testing-library.tsx | 5 +- .../src/SqlLab/components/SouthPane/index.tsx | 12 + .../src/SqlLab/components/SqlEditor/index.tsx | 45 + superset-frontend/src/core/authentication.ts | 27 + superset-frontend/src/core/commands.ts | 64 + superset-frontend/src/core/core.ts | 195 ++ superset-frontend/src/core/environment.ts | 57 + superset-frontend/src/core/extensions.ts | 32 + superset-frontend/src/core/index.ts | 24 + superset-frontend/src/core/sqlLab.ts | 203 ++ superset-frontend/src/core/utils.ts | 45 + .../extensions/ExtensionPlaceholder.test.tsx | 43 + .../src/extensions/ExtensionPlaceholder.tsx | 32 + .../src/extensions/ExtensionsContext.test.tsx | 150 + .../src/extensions/ExtensionsContext.tsx | 93 + .../extensions/ExtensionsContextUtils.test.ts | 74 + .../src/extensions/ExtensionsContextUtils.ts | 32 + .../src/extensions/ExtensionsList.test.tsx | 99 + .../src/extensions/ExtensionsList.tsx | 123 + .../src/extensions/ExtensionsManager.test.ts | 568 ++++ .../src/extensions/ExtensionsManager.ts | 329 ++ .../src/extensions/ExtensionsStartup.test.tsx | 205 ++ .../src/extensions/ExtensionsStartup.tsx | 91 + .../EncryptedField.test.tsx | 5 + .../AddDataset/Footer/Footer.test.tsx | 4 + .../src/hooks/apiResources/queries.test.ts | 28 +- superset-frontend/src/views/App.tsx | 2 + .../src/views/RootContextProviders.tsx | 17 +- superset-frontend/src/views/routes.tsx | 11 + superset-frontend/src/views/store.ts | 7 +- superset-frontend/tsconfig.json | 7 +- superset-frontend/webpack.config.js | 28 +- superset/app.py | 8 +- superset/config.py | 9 + superset/core/__init__.py | 16 + superset/core/api/__init__.py | 16 + superset/core/api/types/__init__.py | 16 + superset/core/api/types/models.py | 72 + superset/core/api/types/query.py | 29 + superset/core/api/types/rest_api.py | 35 + superset/daos/base.py | 26 + superset/extensions/api.py | 215 ++ superset/extensions/discovery.py | 69 + superset/extensions/exceptions.py | 48 + .../extensions/local_extensions_watcher.py | 112 + superset/extensions/types.py | 36 + superset/extensions/utils.py | 219 ++ superset/extensions/view.py | 34 + superset/initialization/__init__.py | 62 + superset/security/manager.py | 1 + 120 files changed, 12309 insertions(+), 264 deletions(-) create mode 100644 .github/workflows/superset-app-cli.yml create mode 100644 superset-cli/LICENSE.txt create mode 100644 superset-cli/README.md create mode 100644 superset-cli/pyproject.toml create mode 100644 superset-cli/src/superset_cli/__init__.py create mode 100644 superset-cli/src/superset_cli/cli.py create mode 100644 superset-cli/src/superset_cli/constants.py create mode 100644 superset-cli/src/superset_cli/templates/backend/pyproject.toml.j2 create mode 100644 superset-cli/src/superset_cli/templates/extension.json.j2 create mode 100644 superset-cli/src/superset_cli/templates/frontend/package.json.j2 create mode 100644 superset-cli/src/superset_cli/utils.py create mode 100644 superset-cli/tests/README.md create mode 100644 superset-cli/tests/__init__.py create mode 100644 superset-cli/tests/conftest.py create mode 100644 superset-cli/tests/test_cli_build.py create mode 100644 superset-cli/tests/test_cli_bundle.py create mode 100644 superset-cli/tests/test_cli_dev.py create mode 100644 superset-cli/tests/test_cli_init.py create mode 100644 superset-cli/tests/test_cli_validate.py create mode 100644 superset-cli/tests/test_templates.py create mode 100644 superset-cli/tests/test_utils.py create mode 100644 superset-cli/tests/utils.py create mode 100644 superset-core/.gitignore create mode 100644 superset-core/LICENSE.txt create mode 100644 superset-core/pyproject.toml create mode 100644 superset-core/src/superset_core/__init__.py create mode 100644 superset-core/src/superset_core/api/__init__.py create mode 100644 superset-core/src/superset_core/api/types/__init__.py create mode 100644 superset-core/src/superset_core/api/types/models.py create mode 100644 superset-core/src/superset_core/api/types/query.py create mode 100644 superset-core/src/superset_core/api/types/rest_api.py create mode 100644 superset-core/src/superset_core/extensions/__init__.py create mode 100644 superset-core/src/superset_core/extensions/types.py create mode 100644 superset-frontend/packages/superset-core/.babelrc.json create mode 100644 superset-frontend/packages/superset-core/package.json create mode 100644 superset-frontend/packages/superset-core/src/api/authentication.ts create mode 100644 superset-frontend/packages/superset-core/src/api/commands.ts create mode 100644 superset-frontend/packages/superset-core/src/api/contributions.ts create mode 100644 superset-frontend/packages/superset-core/src/api/core.ts create mode 100644 superset-frontend/packages/superset-core/src/api/environment.ts create mode 100644 superset-frontend/packages/superset-core/src/api/extensions.ts create mode 100644 superset-frontend/packages/superset-core/src/api/index.ts create mode 100644 superset-frontend/packages/superset-core/src/api/sqlLab.ts create mode 100644 superset-frontend/packages/superset-core/src/index.ts create mode 100644 superset-frontend/packages/superset-core/tsconfig.json create mode 100644 superset-frontend/packages/superset-ui-core/src/components/Popconfirm/index.tsx create mode 100644 superset-frontend/src/core/authentication.ts create mode 100644 superset-frontend/src/core/commands.ts create mode 100644 superset-frontend/src/core/core.ts create mode 100644 superset-frontend/src/core/environment.ts create mode 100644 superset-frontend/src/core/extensions.ts create mode 100644 superset-frontend/src/core/index.ts create mode 100644 superset-frontend/src/core/sqlLab.ts create mode 100644 superset-frontend/src/core/utils.ts create mode 100644 superset-frontend/src/extensions/ExtensionPlaceholder.test.tsx create mode 100644 superset-frontend/src/extensions/ExtensionPlaceholder.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsContext.test.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsContext.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsContextUtils.test.ts create mode 100644 superset-frontend/src/extensions/ExtensionsContextUtils.ts create mode 100644 superset-frontend/src/extensions/ExtensionsList.test.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsList.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsManager.test.ts create mode 100644 superset-frontend/src/extensions/ExtensionsManager.ts create mode 100644 superset-frontend/src/extensions/ExtensionsStartup.test.tsx create mode 100644 superset-frontend/src/extensions/ExtensionsStartup.tsx create mode 100644 superset/core/__init__.py create mode 100644 superset/core/api/__init__.py create mode 100644 superset/core/api/types/__init__.py create mode 100644 superset/core/api/types/models.py create mode 100644 superset/core/api/types/query.py create mode 100644 superset/core/api/types/rest_api.py create mode 100644 superset/extensions/api.py create mode 100644 superset/extensions/discovery.py create mode 100644 superset/extensions/exceptions.py create mode 100644 superset/extensions/local_extensions_watcher.py create mode 100644 superset/extensions/types.py create mode 100644 superset/extensions/utils.py create mode 100644 superset/extensions/view.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59344b28bb0..7378c808fd3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,3 +30,13 @@ **/*.geojson @villebro @rusackas /superset-frontend/plugins/legacy-plugin-chart-country-map/ @villebro @rusackas + +# Notify PMC members of changes to extension-related files + +/superset-core/ @michael-s-molina @villebro +/superset-cli/ @michael-s-molina @villebro +/superset/core/ @michael-s-molina @villebro +/superset/extensions/ @michael-s-molina @villebro +/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro +/superset-frontend/src/core/ @michael-s-molina @villebro +/superset-frontend/src/extensions/ @michael-s-molina @villebro diff --git a/.github/actions/change-detector/action.yml b/.github/actions/change-detector/action.yml index d0f356e771d..8ff54ca614b 100644 --- a/.github/actions/change-detector/action.yml +++ b/.github/actions/change-detector/action.yml @@ -1,24 +1,27 @@ -name: 'Change Detector' -description: 'Detects file changes for pull request and push events' +name: Change Detector +description: Detects file changes for pull request and push events inputs: token: - description: 'GitHub token for authentication' + description: GitHub token for authentication required: true outputs: python: - description: 'Whether Python-related files were changed' + description: Whether Python-related files were changed value: ${{ steps.change-detector.outputs.python }} frontend: - description: 'Whether frontend-related files were changed' + description: Whether frontend-related files were changed value: ${{ steps.change-detector.outputs.frontend }} docker: - description: 'Whether docker-related files were changed' + description: Whether docker-related files were changed value: ${{ steps.change-detector.outputs.docker }} docs: - description: 'Whether docs-related files were changed' + description: Whether docs-related files were changed value: ${{ steps.change-detector.outputs.docs }} + superset-cli: + description: Whether superset-cli package-related files were changed + value: ${{ steps.change-detector.outputs.superset-cli }} runs: - using: 'composite' + using: composite steps: - name: Detect file changes id: change-detector diff --git a/.github/workflows/superset-app-cli.yml b/.github/workflows/superset-app-cli.yml new file mode 100644 index 00000000000..f506dba3d57 --- /dev/null +++ b/.github/workflows/superset-app-cli.yml @@ -0,0 +1,67 @@ +name: Superset App CLI tests + +on: + push: + branches: + - "master" + - "[0-9].[0-9]*" + pull_request: + types: [synchronize, opened, reopened, ready_for_review] + +# cancel previous workflow jobs for PRs +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + test-load-examples: + runs-on: ubuntu-24.04 + env: + PYTHONPATH: ${{ github.workspace }} + SUPERSET_CONFIG: tests.integration_tests.superset_test_config + REDIS_PORT: 16379 + SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: superset + POSTGRES_PASSWORD: superset + ports: + # Use custom ports for services to avoid accidentally connecting to + # GitHub action runner's default installations + - 15432:5432 + redis: + image: redis:7-alpine + ports: + - 16379:6379 + steps: + - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" + uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Check for file changes + id: check + uses: ./.github/actions/change-detector/ + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Python + if: steps.check.outputs.python + uses: ./.github/actions/setup-backend/ + - name: Setup Postgres + if: steps.check.outputs.python + uses: ./.github/actions/cached-dependencies + with: + run: setup-postgres + - name: superset init + if: steps.check.outputs.python + run: | + pip install -e . + superset db upgrade + superset load_test_users + - name: superset load_examples + if: steps.check.outputs.python + run: | + # load examples without test data + superset load_examples --load-big-data diff --git a/.github/workflows/superset-cli.yml b/.github/workflows/superset-cli.yml index 1fe02f30e94..b1833f1f15e 100644 --- a/.github/workflows/superset-cli.yml +++ b/.github/workflows/superset-cli.yml @@ -1,4 +1,4 @@ -name: Superset CLI tests +name: Superset CLI Package Tests on: push: @@ -14,54 +14,51 @@ concurrency: cancel-in-progress: true jobs: - test-load-examples: + test-superset-cli-package: runs-on: ubuntu-24.04 - env: - PYTHONPATH: ${{ github.workspace }} - SUPERSET_CONFIG: tests.integration_tests.superset_test_config - REDIS_PORT: 16379 - SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: superset - POSTGRES_PASSWORD: superset - ports: - # Use custom ports for services to avoid accidentally connecting to - # GitHub action runner's default installations - - 15432:5432 - redis: - image: redis:7-alpine - ports: - - 16379:6379 + strategy: + matrix: + python-version: ["previous", "current", "next"] + defaults: + run: + working-directory: superset-cli steps: - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )" uses: actions/checkout@v4 with: persist-credentials: false submodules: recursive + - name: Check for file changes id: check uses: ./.github/actions/change-detector/ with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Python - if: steps.check.outputs.python + if: steps.check.outputs.superset-cli uses: ./.github/actions/setup-backend/ - - name: Setup Postgres - if: steps.check.outputs.python - uses: ./.github/actions/cached-dependencies with: - run: setup-postgres - - name: superset init - if: steps.check.outputs.python + python-version: ${{ matrix.python-version }} + requirements-type: dev + + - name: Run pytest with coverage + if: steps.check.outputs.superset-cli run: | - pip install -e . - superset db upgrade - superset load_test_users - - name: superset load_examples - if: steps.check.outputs.python - run: | - # load examples without test data - superset load_examples --load-big-data + pytest --cov=superset_cli --cov-report=xml --cov-report=term-missing --cov-report=html -v --tb=short + + - name: Upload coverage reports to Codecov + if: steps.check.outputs.superset-cli + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: superset-cli + name: superset-cli-coverage + fail_ci_if_error: false + + - name: Upload HTML coverage report + if: steps.check.outputs.superset-cli + uses: actions/upload-artifact@v4 + with: + name: superset-cli-coverage-html + path: htmlcov/ diff --git a/.gitignore b/.gitignore index ad38c493e57..9c9fc39d173 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,7 @@ _modules _static build app.db -apache_superset.egg-info/ +*.egg-info/ changelog.sh dist dump.rdb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 58bf9c65fbe..26392b419ac 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,9 @@ repos: rev: v1.15.0 hooks: - id: mypy + name: mypy (main) args: [--check-untyped-defs] + exclude: ^superset-cli/ additional_dependencies: [ types-simplejson, types-python-dateutil, @@ -38,6 +40,10 @@ repos: types-paramiko, types-Markdown, ] + - id: mypy + name: mypy (superset-cli) + args: [--check-untyped-defs] + files: ^superset-cli/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: @@ -54,25 +60,25 @@ repos: args: ["--markdown-linebreak-ext=md"] - repo: local hooks: - - id: eslint-frontend - name: eslint (frontend) - entry: ./scripts/eslint.sh - language: system - pass_filenames: true - files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$ - - id: eslint-docs - name: eslint (docs) - entry: bash -c 'cd docs && FILES=$(echo "$@" | sed "s|docs/||g") && yarn eslint --fix --ext .js,.jsx,.ts,.tsx --quiet $FILES' - language: system - pass_filenames: true - files: ^docs/.*\.(js|jsx|ts|tsx)$ - - id: type-checking-frontend - name: Type-Checking (Frontend) - entry: ./scripts/check-type.js package=superset-frontend excludeDeclarationDir=cypress-base - language: system - files: ^superset-frontend\/.*\.(js|jsx|ts|tsx)$ - exclude: ^superset-frontend/cypress-base\/ - require_serial: true + - id: eslint-frontend + name: eslint (frontend) + entry: ./scripts/eslint.sh + language: system + pass_filenames: true + files: ^superset-frontend/.*\.(js|jsx|ts|tsx)$ + - id: eslint-docs + name: eslint (docs) + entry: bash -c 'cd docs && FILES=$(echo "$@" | sed "s|docs/||g") && yarn eslint --fix --ext .js,.jsx,.ts,.tsx --quiet $FILES' + language: system + pass_filenames: true + files: ^docs/.*\.(js|jsx|ts|tsx)$ + - id: type-checking-frontend + name: Type-Checking (Frontend) + entry: ./scripts/check-type.js package=superset-frontend excludeDeclarationDir=cypress-base + language: system + files: ^superset-frontend\/.*\.(js|jsx|ts|tsx)$ + exclude: ^superset-frontend/cypress-base\/ + require_serial: true # blacklist unsafe functions like make_url (see #19526) - repo: https://github.com/skorokithakis/blacklist-pre-commit-hook rev: e2f070289d8eddcaec0b580d3bde29437e7c8221 @@ -94,21 +100,21 @@ repos: args: [--fix] - repo: local hooks: - - id: pylint - name: pylint with custom Superset plugins - entry: bash - language: system - types: [python] - exclude: ^(tests/|superset/migrations/|scripts/|RELEASING/|docker/) - args: - - -c - - | - TARGET_BRANCH=${GITHUB_BASE_REF:-master} - git fetch origin "$TARGET_BRANCH" - BASE=$(git merge-base origin/"$TARGET_BRANCH" HEAD) - files=$(git diff --name-only --diff-filter=ACM "$BASE"..HEAD | grep '^superset/.*\.py$' || true) - if [ -n "$files" ]; then - pylint --rcfile=.pylintrc --load-plugins=superset.extensions.pylint --reports=no $files - else - echo "No Python files to lint." - fi + - id: pylint + name: pylint with custom Superset plugins + entry: bash + language: system + types: [python] + exclude: ^(tests/|superset/migrations/|scripts/|RELEASING/|docker/) + args: + - -c + - | + TARGET_BRANCH=${GITHUB_BASE_REF:-master} + git fetch origin "$TARGET_BRANCH" + BASE=$(git merge-base origin/"$TARGET_BRANCH" HEAD) + files=$(git diff --name-only --diff-filter=ACM "$BASE"..HEAD | grep '^superset/.*\.py$' || true) + if [ -n "$files" ]; then + pylint --rcfile=.pylintrc --load-plugins=superset.extensions.pylint --reports=no $files + else + echo "No Python files to lint." + fi diff --git a/.rat-excludes b/.rat-excludes index 3a9b0a75f4d..a8460b00ec9 100644 --- a/.rat-excludes +++ b/.rat-excludes @@ -32,6 +32,8 @@ apache_superset.egg-info # json and csv in general cannot have comments .*json .*csv +# jinja templates often need to be as-is +.*j2 # Generated doc files env/* docs/.htaccess* diff --git a/Dockerfile b/Dockerfile index 46ff49ef54f..6dee6b31058 100644 --- a/Dockerfile +++ b/Dockerfile @@ -219,6 +219,10 @@ FROM python-common AS lean # Install Python dependencies using docker/pip-install.sh COPY requirements/base.txt requirements/ + +# Copy superset-core package needed for editable install in base.txt +COPY superset-core superset-core + RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \ /app/docker/pip-install.sh --requires-build-essential -r requirements/base.txt # Install the superset package @@ -241,6 +245,11 @@ RUN /app/docker/apt-install.sh \ # Copy development requirements and install them COPY requirements/*.txt requirements/ + +# Copy local packages needed for editable installs in development.txt +COPY superset-core superset-core +COPY superset-cli superset-cli + # Install Python dependencies using docker/pip-install.sh RUN --mount=type=cache,target=${SUPERSET_HOME}/.cache/uv \ /app/docker/pip-install.sh --requires-build-essential -r requirements/development.txt diff --git a/pyproject.toml b/pyproject.toml index c0673c69468..952e605d396 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ + "apache-superset-core>=0.0.1, <0.2", "backoff>=1.8.0", "celery>=5.3.6, <6.0.0", "click>=8.0.3", @@ -101,6 +102,7 @@ dependencies = [ "tabulate>=0.9.0, <1.0", "typing-extensions>=4, <5", "waitress; sys_platform == 'win32'", + "watchdog>=6.0.0", "wtforms>=2.3.3, <4", "wtforms-json", "xlsxwriter>=3.0.7, <3.1", @@ -190,6 +192,7 @@ doris = ["pydoris>=1.0.0, <2.0.0"] oceanbase = ["oceanbase_py>=0.0.1"] ydb = ["ydb-sqlalchemy>=0.1.2"] development = [ + "apache-superset-cli>=0.0.1, <0.2", "docker", "flask-testing", "freezegun", @@ -221,7 +224,7 @@ documentation = "https://superset.apache.org/docs/intro" combine_as_imports = true include_trailing_comma = true line_length = 88 -known_first_party = "superset" +known_first_party = "superset, apache-superset-core, apache-superset-cli" known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, prison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml" multi_line_output = 3 order_by_type = false @@ -424,3 +427,7 @@ python-geohash = "0" # TODO REMOVE THESE DEPS FROM CODEBASE paramiko = "3" # GPL pyxlsb = "1" # GPL + +[tool.uv.sources] +apache-superset-core = { path = "./superset-core", editable = true } +apache-superset-cli = { path = "./superset-cli", editable = true } diff --git a/requirements/base.txt b/requirements/base.txt index c64d081addd..7584a9afbf7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,7 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt +-e ./superset-core + # via apache-superset (pyproject.toml) alembic==1.15.2 # via flask-migrate amqp==5.3.1 @@ -113,7 +115,9 @@ flask==2.3.3 # flask-sqlalchemy # flask-wtf flask-appbuilder==4.8.0 - # via apache-superset (pyproject.toml) + # via + # apache-superset (pyproject.toml) + # apache-superset-core flask-babel==2.0.0 # via flask-appbuilder flask-caching==2.3.1 @@ -267,7 +271,7 @@ parsedatetime==2.6 pgsanity==0.2.9 # via apache-superset (pyproject.toml) pillow==11.3.0 - # via apache_superset (pyproject.toml) + # via apache-superset (pyproject.toml) platformdirs==4.3.8 # via requests-cache ply==3.11 @@ -419,6 +423,8 @@ vine==5.1.0 # amqp # celery # kombu +watchdog==6.0.0 + # via apache-superset (pyproject.toml) wcwidth==0.2.13 # via prompt-toolkit websocket-client==1.8.0 diff --git a/requirements/development.in b/requirements/development.in index 3a526c991a4..53a48f2f2dc 100644 --- a/requirements/development.in +++ b/requirements/development.in @@ -17,3 +17,4 @@ # under the License. # -e .[development,bigquery,druid,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails] +-e ./superset-cli[test] diff --git a/requirements/development.txt b/requirements/development.txt index b210597e7cb..1c1f8f9c046 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -1,28 +1,36 @@ # This file was autogenerated by uv via the following command: -# uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt +# uv pip compile requirements/development.in -c requirements/base-constraint.txt -o requirements/development.txt -e . # via -r requirements/development.in +-e ./superset-cli + # via + # -r requirements/development.in + # apache-superset +-e ./superset-core + # via + # apache-superset + # apache-superset-cli alembic==1.15.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-migrate amqp==5.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # kombu apispec==6.6.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder apsw==3.50.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # shillelagh astroid==3.3.10 # via pylint attrs==25.3.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # cattrs # jsonschema # outcome @@ -31,69 +39,70 @@ attrs==25.3.0 # trio babel==2.17.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-babel backoff==2.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset bcrypt==4.3.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # paramiko billiard==4.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # celery blinker==1.9.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask bottleneck==1.5.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset brotli==1.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-compress cachelib==0.13.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-caching # flask-session cachetools==5.5.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # google-auth cattrs==25.1.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # requests-cache celery==5.5.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset certifi==2025.6.15 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # requests # selenium cffi==1.17.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # cryptography # pynacl cfgv==3.4.0 # via pre-commit charset-normalizer==3.4.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # requests click==8.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset + # apache-superset-cli # celery # click-didyoumean # click-option-group @@ -103,25 +112,25 @@ click==8.2.1 # flask-appbuilder click-didyoumean==0.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # celery click-option-group==0.5.7 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset click-plugins==1.1.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # celery click-repl==0.3.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # celery cmdstanpy==1.1.0 # via prophet colorama==0.4.6 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder contourpy==1.0.7 @@ -130,15 +139,15 @@ coverage==7.6.8 # via pytest-cov cron-descriptor==1.4.5 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset croniter==6.0.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset cryptography==44.0.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # paramiko # pyopenssl @@ -148,15 +157,15 @@ db-dtypes==1.3.1 # via pandas-gbq defusedxml==0.7.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # odfpy deprecated==1.2.18 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # limits deprecation==2.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset dill==0.4.0 # via pylint @@ -164,23 +173,23 @@ distlib==0.3.8 # via virtualenv dnspython==2.7.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # email-validator docker==7.0.0 # via apache-superset email-validator==2.2.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder et-xmlfile==2.0.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # openpyxl filelock==3.12.2 # via virtualenv flask==2.3.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder # flask-babel @@ -197,59 +206,60 @@ flask==2.3.3 # flask-wtf flask-appbuilder==4.8.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset + # apache-superset-core flask-babel==2.0.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder flask-caching==2.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-compress==1.17 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-cors==4.0.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-jwt-extended==4.7.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder flask-limiter==3.12 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder flask-login==0.6.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder flask-migrate==3.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-session==0.8.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-sqlalchemy==2.5.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder # flask-migrate flask-talisman==1.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset flask-testing==0.8.1 # via apache-superset flask-wtf==1.2.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder fonttools==4.55.0 @@ -260,11 +270,11 @@ future==1.0.0 # via pyhive geographiclib==2.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # geopy geopy==2.4.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset gevent==24.2.1 # via apache-superset @@ -277,7 +287,7 @@ google-api-core==2.23.0 # sqlalchemy-bigquery google-auth==2.40.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # google-api-core # google-auth-oauthlib # google-cloud-bigquery @@ -309,7 +319,7 @@ googleapis-common-protos==1.66.0 # grpcio-status greenlet==3.1.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # gevent # shillelagh @@ -322,30 +332,30 @@ grpcio-status==1.60.1 # via google-api-core gunicorn==23.0.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset h11==0.16.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # wsproto hashids==1.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset holidays==0.25 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # prophet humanize==4.12.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset identify==2.5.36 # via pre-commit idna==3.10 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # email-validator # requests # trio @@ -356,27 +366,28 @@ iniconfig==2.0.0 # via pytest isodate==0.7.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset isort==6.0.1 # via pylint itsdangerous==2.2.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask # flask-wtf jinja2==3.1.6 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt + # apache-superset-cli # flask # flask-babel jsonpath-ng==1.7.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset jsonschema==4.23.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder # openapi-schema-validator # openapi-spec-validator @@ -384,54 +395,54 @@ jsonschema-path==0.3.4 # via openapi-spec-validator jsonschema-specifications==2025.4.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # jsonschema # openapi-schema-validator kiwisolver==1.4.7 # via matplotlib kombu==5.5.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # celery korean-lunar-calendar==0.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # holidays lazy-object-proxy==1.10.0 # via openapi-spec-validator limits==5.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-limiter mako==1.3.10 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # alembic # apache-superset markdown==3.8 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset markdown-it-py==3.0.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # rich markupsafe==3.0.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # jinja2 # mako # werkzeug # wtforms marshmallow==3.26.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder # marshmallow-sqlalchemy marshmallow-sqlalchemy==1.4.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder matplotlib==3.9.0 # via prophet @@ -439,27 +450,27 @@ mccabe==0.7.0 # via pylint mdurl==0.1.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # markdown-it-py msgpack==1.0.8 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset msgspec==0.19.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-session mysqlclient==2.2.6 # via apache-superset nh3==0.2.21 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset nodeenv==1.8.0 # via pre-commit numpy==1.26.4 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # bottleneck # cmdstanpy @@ -473,30 +484,30 @@ oauthlib==3.2.2 # via requests-oauthlib odfpy==1.4.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pandas openapi-schema-validator==0.6.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # openapi-spec-validator openapi-spec-validator==0.7.1 # via apache-superset openpyxl==3.1.5 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pandas ordered-set==4.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-limiter outcome==1.3.0.post0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # trio # trio-websocket packaging==25.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # apispec # db-dtypes @@ -512,7 +523,7 @@ packaging==25.0 # sqlalchemy-bigquery pandas==2.0.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # cmdstanpy # db-dtypes @@ -524,28 +535,29 @@ parameterized==0.9.0 # via apache-superset paramiko==3.5.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # sshtunnel parsedatetime==2.6 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset pathable==0.4.3 # via jsonschema-path pgsanity==0.2.9 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset pillow==11.3.0 # via + # -c requirements/base-constraint.txt # apache-superset # matplotlib pip==25.1.1 # via apache-superset platformdirs==4.3.8 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pylint # requests-cache # virtualenv @@ -553,23 +565,23 @@ pluggy==1.5.0 # via pytest ply==3.11 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # jsonpath-ng polyline==2.0.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset pre-commit==4.1.0 # via apache-superset prison==0.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-appbuilder progress==1.6 # via apache-superset prompt-toolkit==3.0.51 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # click-repl prophet==1.1.5 # via apache-superset @@ -590,24 +602,24 @@ psycopg2-binary==2.9.6 # via apache-superset pyarrow==16.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # db-dtypes # pandas-gbq pyasn1==0.6.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pyasn1-modules # python-ldap # rsa pyasn1-modules==0.4.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # google-auth # python-ldap pycparser==2.22 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # cffi pydata-google-auth==1.9.0 # via pandas-gbq @@ -617,7 +629,7 @@ pyfakefs==5.3.5 # via apache-superset pygments==2.19.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # rich pyhive==0.7.0 # via apache-superset @@ -625,7 +637,7 @@ pyinstrument==4.4.0 # via apache-superset pyjwt==2.10.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder # flask-jwt-extended @@ -633,33 +645,38 @@ pylint==3.3.7 # via apache-superset pynacl==1.5.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # paramiko pyopenssl==25.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # shillelagh pyparsing==3.2.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # matplotlib pysocks==1.7.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # urllib3 pytest==7.4.4 # via # apache-superset + # apache-superset-cli # pytest-cov # pytest-mock pytest-cov==6.0.0 - # via apache-superset + # via + # apache-superset + # apache-superset-cli pytest-mock==3.10.0 - # via apache-superset + # via + # apache-superset + # apache-superset-cli python-dateutil==2.9.0.post0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # celery # croniter @@ -674,45 +691,45 @@ python-dateutil==2.9.0.post0 # trino python-dotenv==1.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset python-geohash==0.8.5 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset python-ldap==3.4.4 # via apache-superset pytz==2025.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # croniter # flask-babel # pandas # trino pyxlsb==1.0.10 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pandas pyyaml==6.0.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # apispec # jsonschema-path # pre-commit redis==4.6.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset referencing==0.36.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # jsonschema # jsonschema-path # jsonschema-specifications requests==2.32.4 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # docker # google-api-core # google-cloud-bigquery @@ -725,33 +742,35 @@ requests==2.32.4 # trino requests-cache==1.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # shillelagh requests-oauthlib==2.0.0 # via google-auth-oauthlib rfc3339-validator==0.1.4 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # openapi-schema-validator rich==13.9.4 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-limiter rpds-py==0.25.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # jsonschema # referencing rsa==4.9.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # google-auth ruff==0.8.0 # via apache-superset selenium==4.32.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset +semver==3.0.4 + # via apache-superset-cli setuptools==80.7.1 # via # nodeenv @@ -761,34 +780,34 @@ setuptools==80.7.1 # zope-interface shillelagh==1.3.5 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset simplejson==3.20.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset six==1.17.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # prison # python-dateutil # rfc3339-validator # wtforms-json slack-sdk==3.35.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset sniffio==1.3.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # trio sortedcontainers==2.4.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # trio sqlalchemy==1.4.54 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # alembic # apache-superset # flask-appbuilder @@ -801,24 +820,24 @@ sqlalchemy-bigquery==1.15.0 # via apache-superset sqlalchemy-utils==0.38.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder sqlglot==27.3.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset sqloxide==0.1.51 # via apache-superset sshtunnel==0.4.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset statsd==4.0.1 # via apache-superset tabulate==0.9.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset tomlkit==0.13.3 # via pylint @@ -830,16 +849,16 @@ trino==0.330.0 # via apache-superset trio==0.30.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # selenium # trio-websocket trio-websocket==0.12.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # selenium typing-extensions==4.14.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # alembic # apache-superset # cattrs @@ -850,71 +869,76 @@ typing-extensions==4.14.0 # shillelagh tzdata==2025.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # kombu # pandas tzlocal==5.2 # via trino url-normalize==2.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # requests-cache urllib3==2.5.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # docker # requests # requests-cache # selenium vine==5.1.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # amqp # celery # kombu virtualenv==20.29.2 # via pre-commit +watchdog==6.0.0 + # via + # -c requirements/base-constraint.txt + # apache-superset + # apache-superset-cli wcwidth==0.2.13 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # prompt-toolkit websocket-client==1.8.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # selenium werkzeug==3.1.3 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask # flask-appbuilder # flask-jwt-extended # flask-login wrapt==1.17.2 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # deprecated wsproto==1.2.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # trio-websocket wtforms==3.2.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # flask-appbuilder # flask-wtf # wtforms-json wtforms-json==0.3.5 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset xlrd==2.0.1 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # pandas xlsxwriter==3.0.9 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # apache-superset # pandas zope-event==5.0 @@ -923,5 +947,5 @@ zope-interface==5.4.0 # via gevent zstandard==0.23.0 # via - # -c requirements/base.txt + # -c requirements/base-constraint.txt # flask-compress diff --git a/scripts/change_detector.py b/scripts/change_detector.py index 9c197b591f7..16950cf09f5 100755 --- a/scripts/change_detector.py +++ b/scripts/change_detector.py @@ -45,6 +45,11 @@ PATTERNS = { "docs": [ r"^docs/", ], + "superset-cli": [ + r"^\.github/workflows/superset-cli\.yml", + r"^superset-cli/", + r"^superset-core/", + ], } GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") diff --git a/scripts/uv-pip-compile.sh b/scripts/uv-pip-compile.sh index f3f12a96363..db46393f13b 100755 --- a/scripts/uv-pip-compile.sh +++ b/scripts/uv-pip-compile.sh @@ -24,8 +24,14 @@ ADDITIONAL_ARGS="$@" # Generate the requirements/base.txt file uv pip compile pyproject.toml requirements/base.in -o requirements/base.txt $ADDITIONAL_ARGS -# Generate the requirements/development.txt file, making sure requirements/base.txt is a constraint to keep the versions in sync. Note that `development.txt` is a Superset of `base.txt` where version for the shared libs should match their version. -uv pip compile requirements/development.in -c requirements/base.txt -o requirements/development.txt $ADDITIONAL_ARGS +# Hack to remove "Unnamed requirements are not allowed as constraints" error from base requirements +grep --invert-match "./superset-core" requirements/base.txt > requirements/base-constraint.txt + +# Generate the requirements/development.txt file, making sure the base requirements are used as a constraint to keep the versions in sync. Note that `development.txt` is a Superset of `base.txt` where version for the shared libs should match their version. +uv pip compile requirements/development.in -c requirements/base-constraint.txt -o requirements/development.txt $ADDITIONAL_ARGS + +# Remove temporary base requirement file +rm requirements/base-constraint.txt # NOTE translation is intended as a "supplemental" set of pins that can be combined with either base or dev as needed uv pip compile requirements/translations.in -o requirements/translations.txt $ADDITIONAL_ARGS diff --git a/superset-cli/LICENSE.txt b/superset-cli/LICENSE.txt new file mode 100644 index 00000000000..56313ab5a54 --- /dev/null +++ b/superset-cli/LICENSE.txt @@ -0,0 +1,216 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed 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. + +============================================================================ + APACHE SUPERSET SUBCOMPONENTS: + + The Apache Superset project contains subcomponents with separate copyright + notices and license terms. Your use of the source code for the these + subcomponents is subject to the terms and conditions of the following + licenses. + +======================================================================== +Third party SIL Open Font License v1.1 (OFL-1.1) +======================================================================== + +(SIL OPEN FONT LICENSE Version 1.1) The Inter font family (https://github.com/rsms/inter) +(SIL OPEN FONT LICENSE Version 1.1) The Fira Code font family (https://github.com/tonsky/FiraCode) diff --git a/superset-cli/README.md b/superset-cli/README.md new file mode 100644 index 00000000000..b8a77f431e5 --- /dev/null +++ b/superset-cli/README.md @@ -0,0 +1,22 @@ + + +# Apache Superset SDK + +This is an SDK tool used for bundling Apache Superset extensions. diff --git a/superset-cli/pyproject.toml b/superset-cli/pyproject.toml new file mode 100644 index 00000000000..73466875724 --- /dev/null +++ b/superset-cli/pyproject.toml @@ -0,0 +1,97 @@ +# 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. + +[project] +name = "apache-superset-cli" +version = "0.0.1" +description = "SDK to build Apache Superset extensions" +authors = [ + { name = "Apache Software Foundation", email = "dev@superset.apache.org" }, +] +license = { file="LICENSE.txt" } +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "apache-superset-core>=0.0.1, <0.2", + "click>=8.0.3", + "jinja2>=3.1.6", + "semver>=3.0.4", + "tomli>=2.2.1; python_version < '3.11'", + "watchdog>=6.0.0", +] + +[project.optional-dependencies] +test = [ + "pytest", + "pytest-cov", + "pytest-mock", +] + +[build-system] +requires = ["setuptools>=76.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["superset_cli"] +package-dir = { "" = "src" } + +[project.scripts] +superset-extensions = "superset_cli.cli:app" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--verbose", + "--cov=superset_cli", + "--cov-report=term-missing", + "--cov-report=html:htmlcov" +] +markers = [ + "unit: Unit tests", + "integration: Integration tests", + "cli: CLI command tests", + "slow: Slow running tests", +] + +[tool.coverage.run] +source = ["src/superset_cli"] +omit = ["*/tests/*", "*/test_*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.ruff.lint.per-file-ignores] +"src/superset_cli/*" = ["TID251"] diff --git a/superset-cli/src/superset_cli/__init__.py b/superset-cli/src/superset_cli/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-cli/src/superset_cli/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-cli/src/superset_cli/cli.py b/superset-cli/src/superset_cli/cli.py new file mode 100644 index 00000000000..626002d8775 --- /dev/null +++ b/superset-cli/src/superset_cli/cli.py @@ -0,0 +1,471 @@ +# 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 json # noqa: TID251 +import re +import shutil +import subprocess +import sys +import time +import zipfile +from pathlib import Path +from typing import Any, Callable, cast + +import click +import semver +from jinja2 import Environment, FileSystemLoader +from superset_core.extensions.types import Manifest, Metadata +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +from superset_cli.constants import MIN_NPM_VERSION +from superset_cli.utils import read_json, read_toml + +REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$") +FRONTEND_DIST_REGEX = re.compile(r"/frontend/dist") + + +def validate_npm() -> None: + """Abort if `npm` is not on PATH.""" + if shutil.which("npm") is None: + click.secho( + "❌ npm is not installed or not on your PATH.", + err=True, + fg="red", + ) + sys.exit(1) + + try: + result = subprocess.run( # noqa: S603 + ["npm", "-v"], # noqa: S607 + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + click.secho( + f"❌ Failed to run `npm -v`: {result.stderr.strip()}", + err=True, + fg="red", + ) + sys.exit(1) + + npm_version = result.stdout.strip() + if semver.compare(npm_version, MIN_NPM_VERSION) < 0: + click.secho( + f"❌ npm version {npm_version} is lower than the required {MIN_NPM_VERSION}.", # noqa: E501 + err=True, + fg="red", + ) + sys.exit(1) + + except FileNotFoundError: + click.secho( + "❌ npm was not found when checking its version.", + err=True, + fg="red", + ) + sys.exit(1) + + +def init_frontend_deps(frontend_dir: Path) -> None: + """ + If node_modules is missing under `frontend_dir`, run `npm ci` if package-lock.json + exists, otherwise run `npm i`. + """ + node_modules = frontend_dir / "node_modules" + if not node_modules.exists(): + package_lock = frontend_dir / "package-lock.json" + if package_lock.exists(): + click.secho("⚙️ node_modules not found, running `npm ci`…", fg="cyan") + npm_command = ["npm", "ci"] + error_msg = "❌ `npm ci` failed. Aborting." + else: + click.secho("⚙️ node_modules not found, running `npm i`…", fg="cyan") + npm_command = ["npm", "i"] + error_msg = "❌ `npm i` failed. Aborting." + + validate_npm() + res = subprocess.run( # noqa: S603 + npm_command, # noqa: S607 + cwd=frontend_dir, + text=True, + ) + if res.returncode != 0: + click.secho(error_msg, err=True, fg="red") + sys.exit(1) + click.secho("✅ Dependencies installed", fg="green") + + +def clean_dist(cwd: Path) -> None: + dist_dir = cwd / "dist" + if dist_dir.exists(): + shutil.rmtree(dist_dir) + dist_dir.mkdir(parents=True) + + +def clean_dist_frontend(cwd: Path) -> None: + frontend_dist = cwd / "dist" / "frontend" + if frontend_dist.exists(): + shutil.rmtree(frontend_dist) + + +def build_manifest(cwd: Path, remote_entry: str | None) -> Manifest: + extension: Metadata = cast(Metadata, read_json(cwd / "extension.json")) + if not extension: + click.secho("❌ extension.json not found.", err=True, fg="red") + sys.exit(1) + + manifest: Manifest = { + "id": extension["id"], + "name": extension["name"], + "version": extension["version"], + "permissions": extension["permissions"], + "dependencies": extension.get("dependencies", []), + } + if ( + (frontend := extension.get("frontend")) + and (contributions := frontend.get("contributions")) + and (module_federation := frontend.get("moduleFederation")) + and remote_entry + ): + manifest["frontend"] = { + "contributions": contributions, + "moduleFederation": module_federation, + "remoteEntry": remote_entry, + } + + if entry_points := extension.get("backend", {}).get("entryPoints"): + manifest["backend"] = {"entryPoints": entry_points} + + return manifest + + +def write_manifest(cwd: Path, manifest: Manifest) -> None: + dist_dir = cwd / "dist" + (dist_dir / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + ) + click.secho("✅ Manifest updated", fg="green") + + +def run_frontend_build(frontend_dir: Path) -> subprocess.CompletedProcess[str]: + click.echo() + click.secho("⚙️ Building frontend assets…", fg="cyan") + return subprocess.run( # noqa: S603 + ["npm", "run", "build"], # noqa: S607 + cwd=frontend_dir, + text=True, + ) + + +def copy_frontend_dist(cwd: Path) -> str: + dist_dir = cwd / "dist" + frontend_dist = cwd / "frontend" / "dist" + remote_entry: str | None = None + + for f in frontend_dist.rglob("*"): + if not f.is_file(): + continue + if REMOTE_ENTRY_REGEX.match(f.name): + remote_entry = f.name + tgt = dist_dir / f.relative_to(cwd) + tgt.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, tgt) + + if not remote_entry: + click.secho("❌ No remote entry file found.", err=True, fg="red") + sys.exit(1) + return remote_entry + + +def copy_backend_files(cwd: Path) -> None: + dist_dir = cwd / "dist" + extension = read_json(cwd / "extension.json") + if not extension: + click.secho("❌ No extension.json file found.", err=True, fg="red") + sys.exit(1) + + for pat in extension.get("backend", {}).get("files", []): + for f in cwd.glob(pat): + if not f.is_file(): + continue + tgt = dist_dir / f.relative_to(cwd) + tgt.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(f, tgt) + + +def rebuild_frontend(cwd: Path, frontend_dir: Path) -> str | None: + """Clean and rebuild frontend, return the remoteEntry filename.""" + clean_dist_frontend(cwd) + + res = run_frontend_build(frontend_dir) + if res.returncode != 0: + click.secho("❌ Frontend build failed", fg="red") + return None + + remote_entry = copy_frontend_dist(cwd) + click.secho("✅ Frontend rebuilt", fg="green") + return remote_entry + + +def rebuild_backend(cwd: Path) -> None: + """Copy backend files (no manifest update).""" + copy_backend_files(cwd) + click.secho("✅ Backend files synced", fg="green") + + +class FrontendChangeHandler(FileSystemEventHandler): + def __init__(self, trigger_build: Callable[[], None]): + self.trigger_build = trigger_build + + def on_any_event(self, event: Any) -> None: + if FRONTEND_DIST_REGEX.search(event.src_path): + return + click.secho(f"🔁 Frontend change detected: {event.src_path}", fg="yellow") + self.trigger_build() + + +@click.group(help="CLI for validating and bundling Superset extensions.") +def app() -> None: + pass + + +@app.command() +def validate() -> None: + validate_npm() + + click.secho("✅ Validation successful", fg="green") + + +@app.command() +@click.pass_context +def build(ctx: click.Context) -> None: + ctx.invoke(validate) + cwd = Path.cwd() + frontend_dir = cwd / "frontend" + backend_dir = cwd / "backend" + + clean_dist(cwd) + + # Build frontend if it exists + remote_entry = None + if frontend_dir.exists(): + init_frontend_deps(frontend_dir) + remote_entry = rebuild_frontend(cwd, frontend_dir) + + # Build backend independently if it exists + if backend_dir.exists(): + pyproject = read_toml(backend_dir / "pyproject.toml") + if pyproject: + rebuild_backend(cwd) + + # Build manifest and write it + manifest = build_manifest(cwd, remote_entry) + write_manifest(cwd, manifest) + + click.secho("✅ Full build completed in dist/", fg="green") + + +@app.command() +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path, dir_okay=True, file_okay=True, writable=True), + help="Optional output path or filename for the bundle.", +) +@click.pass_context +def bundle(ctx: click.Context, output: Path | None) -> None: + ctx.invoke(build) + + cwd = Path.cwd() + dist_dir = cwd / "dist" + manifest_path = dist_dir / "manifest.json" + + if not manifest_path.exists(): + click.secho( + "❌ dist/manifest.json not found. Run `build` first.", err=True, fg="red" + ) + sys.exit(1) + + manifest = json.loads(manifest_path.read_text()) + id_ = manifest["id"] + version = manifest["version"] + default_filename = f"{id_}-{version}.supx" + + if output is None: + zip_path = Path(default_filename) + elif output.is_dir(): + zip_path = output / default_filename + else: + zip_path = output + + try: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf: + for file in dist_dir.rglob("*"): + if file.is_file(): + arcname = file.relative_to(dist_dir) + zipf.write(file, arcname) + except Exception as ex: + click.secho(f"❌ Failed to create bundle: {ex}", err=True, fg="red") + sys.exit(1) + + click.secho(f"✅ Bundle created: {zip_path}", fg="green") + + +@app.command() +@click.pass_context +def dev(ctx: click.Context) -> None: + cwd = Path.cwd() + frontend_dir = cwd / "frontend" + backend_dir = cwd / "backend" + + clean_dist(cwd) + + # Build frontend if it exists + remote_entry = None + if frontend_dir.exists(): + init_frontend_deps(frontend_dir) + remote_entry = rebuild_frontend(cwd, frontend_dir) + + # Build backend if it exists + if backend_dir.exists(): + rebuild_backend(cwd) + + manifest = build_manifest(cwd, remote_entry) + write_manifest(cwd, manifest) + + def frontend_watcher() -> None: + if frontend_dir.exists(): + if (remote_entry := rebuild_frontend(cwd, frontend_dir)) is not None: + manifest = build_manifest(cwd, remote_entry) + write_manifest(cwd, manifest) + + def backend_watcher() -> None: + if backend_dir.exists(): + rebuild_backend(cwd) + dist_dir = cwd / "dist" + manifest_path = dist_dir / "manifest.json" + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text()) + write_manifest(cwd, manifest) + + # Build watch message based on existing directories + watch_dirs = [] + if frontend_dir.exists(): + watch_dirs.append(str(frontend_dir)) + if backend_dir.exists(): + watch_dirs.append(str(backend_dir)) + + if watch_dirs: + click.secho(f"👀 Watching for changes in: {', '.join(watch_dirs)}", fg="green") + else: + click.secho("⚠️ No frontend or backend directories found to watch", fg="yellow") + + observer = Observer() + + # Only set up watchers for directories that exist + if frontend_dir.exists(): + frontend_handler = FrontendChangeHandler(trigger_build=frontend_watcher) + observer.schedule(frontend_handler, str(frontend_dir), recursive=True) + + if backend_dir.exists(): + backend_handler = FileSystemEventHandler() + backend_handler.on_any_event = lambda event: backend_watcher() + observer.schedule(backend_handler, str(backend_dir), recursive=True) + + if watch_dirs: + observer.start() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + click.secho("\n🛑 Stopping watch mode", fg="blue") + observer.stop() + + observer.join() + else: + click.secho("❌ No directories to watch. Exiting.", fg="red") + + +@app.command() +def init() -> None: + id_ = 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) + + name = click.prompt("Extension name (human-readable display name)", type=str) + version = click.prompt("Initial version", default="0.1.0") + license = click.prompt("License", default="Apache-2.0") + include_frontend = click.confirm("Include frontend?", default=True) + include_backend = click.confirm("Include backend?", default=True) + + target_dir = Path.cwd() / id_ + if target_dir.exists(): + click.secho(f"❌ Directory {target_dir} already exists.", fg="red") + sys.exit(1) + + # Set up Jinja environment + templates_dir = Path(__file__).parent / "templates" + env = Environment(loader=FileSystemLoader(templates_dir)) # noqa: S701 + ctx = { + "id": id_, + "name": name, + "include_frontend": include_frontend, + "include_backend": include_backend, + "license": license, + "version": version, + } + + # Create base directory + target_dir.mkdir() + extension_json = env.get_template("extension.json.j2").render(ctx) + (target_dir / "extension.json").write_text(extension_json) + click.secho("✅ Created extension.json", fg="green") + + # Copy frontend template + if include_frontend: + frontend_dir = target_dir / "frontend" + frontend_dir.mkdir() + + # package.json + package_json = env.get_template("frontend/package.json.j2").render(ctx) + (frontend_dir / "package.json").write_text(package_json) + click.secho("✅ Created frontend folder structure", fg="green") + + # Copy backend template + if include_backend: + backend_dir = target_dir / "backend" + backend_dir.mkdir() + + # pyproject.toml + pyproject_toml = env.get_template("backend/pyproject.toml.j2").render(ctx) + (backend_dir / "pyproject.toml").write_text(pyproject_toml) + + click.secho("✅ Created backend folder structure", fg="green") + + click.secho( + f"🎉 Extension {name} (ID: {id_}) initialized at {target_dir}", fg="cyan" + ) + + +if __name__ == "__main__": + app() diff --git a/superset-cli/src/superset_cli/constants.py b/superset-cli/src/superset_cli/constants.py new file mode 100644 index 00000000000..402d6fa3c25 --- /dev/null +++ b/superset-cli/src/superset_cli/constants.py @@ -0,0 +1,19 @@ +# 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. + + +MIN_NPM_VERSION = "10.8.2" diff --git a/superset-cli/src/superset_cli/templates/backend/pyproject.toml.j2 b/superset-cli/src/superset_cli/templates/backend/pyproject.toml.j2 new file mode 100644 index 00000000000..cbe78bd8b29 --- /dev/null +++ b/superset-cli/src/superset_cli/templates/backend/pyproject.toml.j2 @@ -0,0 +1,4 @@ +[project] +name = "{{ id }}" +version = "{{ version }}" +license = "{{ license }}" diff --git a/superset-cli/src/superset_cli/templates/extension.json.j2 b/superset-cli/src/superset_cli/templates/extension.json.j2 new file mode 100644 index 00000000000..cc9ba36ebf8 --- /dev/null +++ b/superset-cli/src/superset_cli/templates/extension.json.j2 @@ -0,0 +1,25 @@ +{ + "id": "{{ id }}", + "name": "{{ name }}", + "version": "{{ version }}", + "license": "{{ license }}", + {% if include_frontend -%} + "frontend": { + "contributions": { + "commands": [], + "views": [], + "menus": [] + }, + "moduleFederation": { + "exposes": ["./index"] + } + }, + {% endif -%} + {% if include_backend -%} + "backend": { + "entryPoints": ["{{ id }}.entrypoint"], + "files": ["backend/src/{{ id }}/**/*.py"] + }, + {% endif -%} + "permissions": [] +} diff --git a/superset-cli/src/superset_cli/templates/frontend/package.json.j2 b/superset-cli/src/superset_cli/templates/frontend/package.json.j2 new file mode 100644 index 00000000000..6ad06143bee --- /dev/null +++ b/superset-cli/src/superset_cli/templates/frontend/package.json.j2 @@ -0,0 +1,34 @@ +{ + "name": "{{ id }}", + "version": "{{ version }}", + "main": "dist/main.js", + "types": "dist/publicAPI.d.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "webpack serve --mode development", + "build": "webpack --stats-error-details --mode production" + }, + "keywords": [], + "private": true, + "author": "", + "license": "{{ license }}", + "description": "", + "peerDependencies": { + "@apache-superset/core": "file:../../../superset-frontend/packages/superset-core", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@types/react": "^19.0.10", + "copy-webpack-plugin": "^13.0.0", + "install": "^0.13.0", + "npm": "^11.1.0", + "ts-loader": "^9.5.2", + "typescript": "^5.8.2", + "webpack": "^5.98.0", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.0" + } +} diff --git a/superset-cli/src/superset_cli/utils.py b/superset-cli/src/superset_cli/utils.py new file mode 100644 index 00000000000..7dc739d9dba --- /dev/null +++ b/superset-cli/src/superset_cli/utils.py @@ -0,0 +1,42 @@ +# 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 json # noqa: TID251 +import sys +from pathlib import Path +from typing import Any + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +def read_toml(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + + with path.open("rb") as f: + return tomllib.load(f) + + +def read_json(path: Path) -> dict[str, Any] | None: + path = Path(path) + if not path.is_file(): + return None + + return json.loads(path.read_text()) diff --git a/superset-cli/tests/README.md b/superset-cli/tests/README.md new file mode 100644 index 00000000000..dbbe5544d85 --- /dev/null +++ b/superset-cli/tests/README.md @@ -0,0 +1,206 @@ + + +# 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. + +# Superset CLI Tests + +This directory contains tests for the superset-cli package, focusing on the `init` command and other CLI functionality. + +## Test Structure + +### Core Test Files + +- **`test_cli_init.py`**: Comprehensive tests for the `init` command scaffolder +- **`test_templates.py`**: Unit tests for Jinja2 template rendering +- **`conftest.py`**: Pytest fixtures and configuration +- **`utils.py`**: Reusable testing utilities and helpers + +### Test Categories + +#### Unit Tests (`@pytest.mark.unit`) + +- Template rendering functionality +- Individual function testing +- Input validation logic + +#### Integration Tests (`@pytest.mark.integration`) + +- Complete CLI command workflows +- End-to-end scaffolding processes + +#### CLI Tests (`@pytest.mark.cli`) + +- Click command interface testing +- User input simulation +- Command output verification + +## Testing Approach for Scaffolders/Generators + +The tests use these patterns for testing code generators: + +### 1. Isolated Environment Testing + +```python +@pytest.fixture +def isolated_filesystem(tmp_path): + """Provide isolated temporary directory for each test.""" +``` + +### 2. Click CLI Testing Framework + +```python +from click.testing import CliRunner +runner = CliRunner() +result = runner.invoke(app, ["init"], input="...") +``` + +### 3. File Structure Validation + +```python +from tests.utils import assert_file_structure, assert_directory_structure +assert_file_structure(extension_path, expected_files) +``` + +### 4. Template Content Verification + +```python +from tests.utils import assert_json_content +assert_json_content(json_path, {"name": "expected_value"}) +``` + +### 5. Parametrized Testing + +```python +@pytest.mark.parametrize("include_frontend,include_backend", [ + (True, True), (True, False), (False, True), (False, False) +]) +``` + +## Key Test Cases + +### Init Command Tests + +- ✅ Creates extension with both frontend and backend +- ✅ Creates frontend-only extensions +- ✅ Creates backend-only extensions +- ✅ Validates extension naming (alphanumeric + underscore only) +- ✅ Handles existing directory conflicts +- ✅ Verifies generated file content accuracy +- ✅ Tests custom version and license inputs +- ✅ Integration test for complete workflow + +### Template Rendering Tests + +- ✅ Extension.json template with various configurations +- ✅ Package.json template rendering +- ✅ Pyproject.toml template rendering +- ✅ Template validation with different names/versions/licenses +- ✅ JSON validity verification +- ✅ Whitespace and formatting checks + +## Running Tests + +### All tests + +```bash +pytest +``` + +### Specific test categories + +```bash +pytest -m unit # Unit tests only +pytest -m integration # Integration tests only +pytest -m cli # CLI tests only +``` + +### With coverage + +```bash +pytest --cov=superset_cli --cov-report=html +``` + +### Specific test files + +```bash +pytest tests/test_cli_init.py +pytest tests/test_templates.py +``` + +## Reusable Testing Infrastructure + +The testing infrastructure is designed for reusability: + +### Test Utilities (`tests/utils.py`) + +- `assert_file_exists()` / `assert_directory_exists()` +- `assert_file_structure()` / `assert_directory_structure()` +- `assert_json_content()` / `load_json_file()` +- `create_test_extension_structure()` - Helper for expected structures + +### Fixtures (`tests/conftest.py`) + +- `cli_runner` - Click CLI runner +- `isolated_filesystem` - Temporary directory with cleanup +- `extension_params` - Default extension parameters +- `cli_input_*` - Pre-configured user inputs + +This infrastructure can be easily extended for testing additional CLI commands like `build`, `bundle`, `dev`, and `validate`. + +## Best Practices Implemented + +1. **Isolation**: Each test runs in its own temporary directory +2. **Comprehensive Coverage**: Tests cover happy paths, edge cases, and error conditions +3. **Realistic Testing**: Uses actual Click CLI runner with realistic user input +4. **Content Verification**: Validates both file existence and content accuracy +5. **Template Testing**: Separates template rendering logic from CLI integration +6. **Reusable Components**: Utilities and fixtures designed for extension +7. **Clear Documentation**: Well-documented test cases and helper functions +8. **Type Safety**: Uses modern Python type annotations with `from __future__ import annotations` diff --git a/superset-cli/tests/__init__.py b/superset-cli/tests/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-cli/tests/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-cli/tests/conftest.py b/superset-cli/tests/conftest.py new file mode 100644 index 00000000000..fdb4cd17b90 --- /dev/null +++ b/superset-cli/tests/conftest.py @@ -0,0 +1,136 @@ +# 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 __future__ import annotations + +import os +from pathlib import Path + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def cli_runner(): + """Provide a Click CLI runner for testing commands.""" + return CliRunner() + + +@pytest.fixture +def isolated_filesystem(tmp_path): + """ + Provide an isolated temporary directory and change to it. + This ensures tests don't interfere with each other. + """ + original_cwd = Path.cwd() + os.chdir(tmp_path) + yield tmp_path + os.chdir(original_cwd) + + +@pytest.fixture +def extension_params(): + """Default parameters for extension creation.""" + return { + "id": "test_extension", + "name": "Test Extension", + "version": "0.1.0", + "license": "Apache-2.0", + "include_frontend": True, + "include_backend": True, + } + + +@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" + + +@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" + + +@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" + + +@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" + + +@pytest.fixture +def extension_setup_for_dev(): + """Set up extension structure for dev testing.""" + + def _setup(base_path: Path) -> None: + import json + + # Create extension.json + extension_json = { + "id": "test_extension", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + } + (base_path / "extension.json").write_text(json.dumps(extension_json)) + + # Create frontend and backend directories + (base_path / "frontend").mkdir() + (base_path / "backend").mkdir() + + return _setup + + +@pytest.fixture +def extension_setup_for_bundling(): + """Set up a complete extension structure ready for bundling.""" + + def _setup(base_path: Path) -> None: + import json + + # Create dist directory with manifest and files + dist_dir = base_path / "dist" + dist_dir.mkdir(parents=True) + + # Create manifest.json + manifest = { + "id": "test_extension", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + } + (dist_dir / "manifest.json").write_text(json.dumps(manifest)) + + # Create some frontend files + frontend_dir = dist_dir / "frontend" / "dist" + frontend_dir.mkdir(parents=True) + (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" + backend_dir.mkdir(parents=True) + (backend_dir / "__init__.py").write_text("# init") + + return _setup diff --git a/superset-cli/tests/test_cli_build.py b/superset-cli/tests/test_cli_build.py new file mode 100644 index 00000000000..37a4d6821bb --- /dev/null +++ b/superset-cli/tests/test_cli_build.py @@ -0,0 +1,552 @@ +# 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 __future__ import annotations + +import json +from unittest.mock import Mock, patch + +import pytest +from superset_cli.cli import ( + app, + build_manifest, + clean_dist, + copy_backend_files, + copy_frontend_dist, + init_frontend_deps, +) + +from tests.utils import ( + assert_directory_exists, + assert_file_exists, +) + + +@pytest.fixture +def extension_with_build_structure(): + """Create extension structure suitable for build testing.""" + + def _create(base_path, include_frontend=True, include_backend=True): + # Create required directories + if include_frontend: + frontend_dir = base_path / "frontend" + frontend_dir.mkdir() + + if include_backend: + backend_dir = base_path / "backend" + backend_dir.mkdir() + + # Create extension.json + extension_json = { + "id": "test_extension", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + } + + if include_frontend: + extension_json["frontend"] = { + "contributions": {"commands": []}, + "moduleFederation": {"exposes": ["./index"]}, + } + + if include_backend: + extension_json["backend"] = {"entryPoints": ["test_extension.entrypoint"]} + + (base_path / "extension.json").write_text(json.dumps(extension_json)) + + return { + "frontend_dir": frontend_dir if include_frontend else None, + "backend_dir": backend_dir if include_backend else None, + } + + return _create + + +# Build Command Tests +@pytest.mark.cli +@patch("superset_cli.cli.validate_npm") +@patch("superset_cli.cli.init_frontend_deps") +@patch("superset_cli.cli.rebuild_frontend") +@patch("superset_cli.cli.rebuild_backend") +@patch("superset_cli.cli.read_toml") +def test_build_command_success_flow( + mock_read_toml, + mock_rebuild_backend, + mock_rebuild_frontend, + mock_init_frontend_deps, + mock_validate_npm, + cli_runner, + isolated_filesystem, + extension_with_build_structure, +): + """Test build command success flow.""" + # Setup mocks + mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" + mock_read_toml.return_value = {"project": {"name": "test"}} + + # Create extension structure + dirs = extension_with_build_structure(isolated_filesystem) + + result = cli_runner.invoke(app, ["build"]) + + assert result.exit_code == 0 + assert "✅ Full build completed in dist/" in result.output + + # Verify function calls + mock_validate_npm.assert_called_once() + mock_init_frontend_deps.assert_called_once_with(dirs["frontend_dir"]) + mock_rebuild_frontend.assert_called_once() + mock_rebuild_backend.assert_called_once() + + +@pytest.mark.cli +@patch("superset_cli.cli.validate_npm") +@patch("superset_cli.cli.init_frontend_deps") +@patch("superset_cli.cli.rebuild_frontend") +def test_build_command_handles_frontend_build_failure( + mock_rebuild_frontend, + mock_init_frontend_deps, + mock_validate_npm, + cli_runner, + isolated_filesystem, + extension_with_build_structure, +): + """Test build command handles frontend build failure.""" + # Setup mocks + mock_rebuild_frontend.return_value = None # Indicates failure + + # Create extension structure + extension_with_build_structure(isolated_filesystem) + + result = cli_runner.invoke(app, ["build"]) + + # Command should complete and create manifest even with frontend failure + assert result.exit_code == 0 + assert "✅ Full build completed in dist/" in result.output + + +# Clean Dist Tests +@pytest.mark.unit +def test_clean_dist_removes_existing_dist_directory(isolated_filesystem): + """Test clean_dist removes existing dist directory and recreates it.""" + # Create dist directory with some content + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir() + (dist_dir / "some_file.txt").write_text("test content") + (dist_dir / "subdir").mkdir() + + clean_dist(isolated_filesystem) + + # Should exist but be empty + assert_directory_exists(dist_dir) + assert list(dist_dir.iterdir()) == [] + + +@pytest.mark.unit +def test_clean_dist_creates_dist_directory_if_missing(isolated_filesystem): + """Test clean_dist creates dist directory when it doesn't exist.""" + dist_dir = isolated_filesystem / "dist" + assert not dist_dir.exists() + + clean_dist(isolated_filesystem) + + assert_directory_exists(dist_dir) + + +# Frontend Dependencies Tests +@pytest.mark.unit +@patch("subprocess.run") +def test_init_frontend_deps_skips_when_node_modules_exists( + mock_run, isolated_filesystem +): + """Test init_frontend_deps skips npm ci when node_modules exists.""" + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + (frontend_dir / "node_modules").mkdir() + + init_frontend_deps(frontend_dir) + + # Should not call subprocess.run for npm ci + mock_run.assert_not_called() + + +@pytest.mark.unit +@patch("subprocess.run") +@patch("superset_cli.cli.validate_npm") +def test_init_frontend_deps_runs_npm_i_when_missing( + mock_validate_npm, mock_run, isolated_filesystem +): + """Test init_frontend_deps runs npm ci when node_modules is missing.""" + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + + # Mock successful npm ci + mock_run.return_value = Mock(returncode=0) + + init_frontend_deps(frontend_dir) + + # Should validate npm and run npm ci + mock_validate_npm.assert_called_once() + mock_run.assert_called_once_with(["npm", "i"], cwd=frontend_dir, text=True) + + +@pytest.mark.unit +@patch("subprocess.run") +@patch("superset_cli.cli.validate_npm") +def test_init_frontend_deps_exits_on_npm_ci_failure( + mock_validate_npm, mock_run, isolated_filesystem +): + """Test init_frontend_deps exits when npm ci fails.""" + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + + # Mock failed npm ci + mock_run.return_value = Mock(returncode=1) + + with pytest.raises(SystemExit) as exc_info: + init_frontend_deps(frontend_dir) + + assert exc_info.value.code == 1 + + +# Build Manifest Tests +@pytest.mark.unit +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", + "version": "1.0.0", + "permissions": ["read_data"], + "dependencies": ["some_dep"], + "frontend": { + "contributions": {"commands": ["test_command"]}, + "moduleFederation": {"exposes": ["./index"]}, + }, + "backend": {"entryPoints": ["test_extension.entrypoint"]}, + } + extension_json = isolated_filesystem / "extension.json" + extension_json.write_text(json.dumps(extension_data)) + + manifest = build_manifest(isolated_filesystem, "remoteEntry.abc123.js") + + # Verify manifest structure + manifest_dict = dict(manifest) + assert manifest_dict["id"] == "test_extension" + assert manifest_dict["name"] == "Test Extension" + assert manifest_dict["version"] == "1.0.0" + assert manifest_dict["permissions"] == ["read_data"] + assert manifest_dict["dependencies"] == ["some_dep"] + + # Verify frontend section + assert "frontend" in manifest + frontend = manifest["frontend"] + assert frontend["contributions"] == {"commands": ["test_command"]} + assert frontend["moduleFederation"] == {"exposes": ["./index"]} + assert frontend["remoteEntry"] == "remoteEntry.abc123.js" + + # Verify backend section + assert "backend" in manifest + assert manifest["backend"]["entryPoints"] == ["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", + "version": "0.1.0", + "permissions": [], + } + extension_json = isolated_filesystem / "extension.json" + extension_json.write_text(json.dumps(extension_data)) + + manifest = build_manifest(isolated_filesystem, None) + + manifest_dict = dict(manifest) + assert manifest_dict["id"] == "minimal_extension" + assert manifest_dict["name"] == "Minimal Extension" + assert manifest_dict["version"] == "0.1.0" + assert manifest_dict["permissions"] == [] + assert manifest_dict["dependencies"] == [] # Default empty list + assert "frontend" not in manifest + assert "backend" not in manifest + + +@pytest.mark.unit +def test_build_manifest_exits_when_extension_json_missing(isolated_filesystem): + """Test build_manifest exits when extension.json is missing.""" + with pytest.raises(SystemExit) as exc_info: + build_manifest(isolated_filesystem, "remoteEntry.js") + + assert exc_info.value.code == 1 + + +# Frontend Build Tests +@pytest.mark.unit +def test_clean_dist_frontend_removes_frontend_dist(isolated_filesystem): + """Test clean_dist_frontend removes frontend/dist directory specifically.""" + from superset_cli.cli import clean_dist_frontend + + # Create dist/frontend structure + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir(parents=True) + frontend_dist = dist_dir / "frontend" + frontend_dist.mkdir() + (frontend_dist / "some_file.js").write_text("content") + + clean_dist_frontend(isolated_filesystem) + + # Frontend dist should be removed, but dist should remain + assert dist_dir.exists() + assert not frontend_dist.exists() + + +@pytest.mark.unit +def test_clean_dist_frontend_handles_nonexistent_directory(isolated_filesystem): + """Test clean_dist_frontend handles case where frontend dist doesn't exist.""" + from superset_cli.cli import clean_dist_frontend + + # No dist directory exists + clean_dist_frontend(isolated_filesystem) + + # Should not raise error + + +@pytest.mark.unit +def test_run_frontend_build_with_output_messages(isolated_filesystem): + """Test run_frontend_build produces expected output messages.""" + from superset_cli.cli import run_frontend_build + + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + + with patch("subprocess.run") as mock_run: + mock_result = Mock(returncode=0) + mock_run.return_value = mock_result + + result = run_frontend_build(frontend_dir) + + assert result.returncode == 0 + mock_run.assert_called_once_with( + ["npm", "run", "build"], cwd=frontend_dir, text=True + ) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "return_code,expected_result", + [ + (0, "remoteEntry.abc123.js"), + (1, None), + ], +) +def test_rebuild_frontend_handles_build_results( + isolated_filesystem, return_code, expected_result +): + """Test rebuild_frontend handles different build results.""" + from superset_cli.cli import rebuild_frontend + + # Create frontend structure + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + + if return_code == 0: + # Create frontend/dist with remoteEntry for success case + frontend_dist = frontend_dir / "dist" + frontend_dist.mkdir() + (frontend_dist / "remoteEntry.abc123.js").write_text("content") + + # Create dist directory + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir() + + with patch("superset_cli.cli.run_frontend_build") as mock_build: + mock_build.return_value = Mock(returncode=return_code) + + result = rebuild_frontend(isolated_filesystem, frontend_dir) + + assert result == expected_result + + +# Backend Build Tests +@pytest.mark.unit +def test_rebuild_backend_calls_copy_and_shows_message(isolated_filesystem): + """Test rebuild_backend calls copy_backend_files and shows success message.""" + from superset_cli.cli import rebuild_backend + + # Create extension.json + extension_json = { + "id": "test", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + } + (isolated_filesystem / "extension.json").write_text(json.dumps(extension_json)) + + with patch("superset_cli.cli.copy_backend_files") as mock_copy: + rebuild_backend(isolated_filesystem) + + mock_copy.assert_called_once_with(isolated_filesystem) + + +@pytest.mark.unit +def test_copy_backend_files_skips_non_files(isolated_filesystem): + """Test copy_backend_files skips directories and non-files.""" + # Create backend structure with directory + backend_src = isolated_filesystem / "backend" / "src" / "test_ext" + backend_src.mkdir(parents=True) + (backend_src / "__init__.py").write_text("# init") + + # Create a subdirectory (should be skipped) + subdir = backend_src / "subdir" + subdir.mkdir() + + # Create extension.json with backend file patterns + extension_data = { + "id": "test_ext", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + "backend": { + "files": ["backend/src/test_ext/**/*"] # Will match both files and dirs + }, + } + (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) + + # Create dist directory + clean_dist(isolated_filesystem) + + copy_backend_files(isolated_filesystem) + + # Verify only files were copied, not directories + dist_dir = isolated_filesystem / "dist" + assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") + + # Directory should not be copied as a file + copied_subdir = dist_dir / "backend" / "src" / "test_ext" / "subdir" + # The directory might exist but should be empty since we skip non-files + if copied_subdir.exists(): + assert list(copied_subdir.iterdir()) == [] + + +@pytest.mark.unit +def test_copy_backend_files_copies_matched_files(isolated_filesystem): + """Test copy_backend_files copies files matching patterns from extension.json.""" + # Create backend source files + backend_src = isolated_filesystem / "backend" / "src" / "test_ext" + backend_src.mkdir(parents=True) + (backend_src / "__init__.py").write_text("# init") + (backend_src / "main.py").write_text("# main") + + # Create extension.json with backend file patterns + extension_data = { + "id": "test_ext", + "name": "Test Extension", + "version": "1.0.0", + "permissions": [], + "backend": {"files": ["backend/src/test_ext/**/*.py"]}, + } + (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) + + # Create dist directory + clean_dist(isolated_filesystem) + + copy_backend_files(isolated_filesystem) + + # Verify files were copied + dist_dir = isolated_filesystem / "dist" + assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "__init__.py") + assert_file_exists(dist_dir / "backend" / "src" / "test_ext" / "main.py") + + +@pytest.mark.unit +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", + "version": "1.0.0", + "permissions": [], + } + (isolated_filesystem / "extension.json").write_text(json.dumps(extension_data)) + + clean_dist(isolated_filesystem) + + # Should not raise error + copy_backend_files(isolated_filesystem) + + +@pytest.mark.unit +def test_copy_backend_files_exits_when_extension_json_missing(isolated_filesystem): + """Test copy_backend_files exits when extension.json is missing.""" + clean_dist(isolated_filesystem) + + with pytest.raises(SystemExit) as exc_info: + copy_backend_files(isolated_filesystem) + + assert exc_info.value.code == 1 + + +# Frontend Dist Copy Tests +@pytest.mark.unit +def test_copy_frontend_dist_copies_files_correctly(isolated_filesystem): + """Test copy_frontend_dist copies frontend build files to dist.""" + # Create frontend/dist structure + frontend_dist = isolated_filesystem / "frontend" / "dist" + frontend_dist.mkdir(parents=True) + + # Create some files including remoteEntry + (frontend_dist / "remoteEntry.abc123.js").write_text("remote entry content") + (frontend_dist / "main.js").write_text("main js content") + + # Create subdirectory with file + assets_dir = frontend_dist / "assets" + assets_dir.mkdir() + (assets_dir / "style.css").write_text("css content") + + # Create dist directory + clean_dist(isolated_filesystem) + + remote_entry = copy_frontend_dist(isolated_filesystem) + + assert remote_entry == "remoteEntry.abc123.js" + + # Verify files were copied + dist_dir = isolated_filesystem / "dist" + assert_file_exists(dist_dir / "frontend" / "dist" / "remoteEntry.abc123.js") + assert_file_exists(dist_dir / "frontend" / "dist" / "main.js") + assert_file_exists(dist_dir / "frontend" / "dist" / "assets" / "style.css") + + +@pytest.mark.unit +def test_copy_frontend_dist_exits_when_no_remote_entry(isolated_filesystem): + """Test copy_frontend_dist exits when no remoteEntry file found.""" + # Create frontend/dist without remoteEntry file + frontend_dist = isolated_filesystem / "frontend" / "dist" + frontend_dist.mkdir(parents=True) + (frontend_dist / "main.js").write_text("main content") + + clean_dist(isolated_filesystem) + + with pytest.raises(SystemExit) as exc_info: + copy_frontend_dist(isolated_filesystem) + + assert exc_info.value.code == 1 diff --git a/superset-cli/tests/test_cli_bundle.py b/superset-cli/tests/test_cli_bundle.py new file mode 100644 index 00000000000..a612c65cc9d --- /dev/null +++ b/superset-cli/tests/test_cli_bundle.py @@ -0,0 +1,255 @@ +# 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 __future__ import annotations + +import json +import zipfile +from unittest.mock import patch + +import pytest +from superset_cli.cli import app + +from tests.utils import assert_file_exists + + +# Bundle Command Tests +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_creates_zip_with_default_name( + mock_build, cli_runner, isolated_filesystem, extension_setup_for_bundling +): + """Test bundle command creates zip file with default name.""" + # Mock the build command to do nothing (we'll set up dist manually) + mock_build.return_value = None + + # Setup extension for bundling (this creates the dist structure) + extension_setup_for_bundling(isolated_filesystem) + + result = cli_runner.invoke(app, ["bundle"]) + + assert result.exit_code == 0 + assert "✅ Bundle created: test_extension-1.0.0.supx" in result.output + + # Verify zip file was created + zip_path = isolated_filesystem / "test_extension-1.0.0.supx" + assert_file_exists(zip_path) + + # Verify zip contents + with zipfile.ZipFile(zip_path, "r") as zipf: + file_list = zipf.namelist() + 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 + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_with_custom_output_filename( + mock_build, cli_runner, isolated_filesystem, extension_setup_for_bundling +): + """Test bundle command with custom output filename.""" + # Mock the build command + mock_build.return_value = None + + extension_setup_for_bundling(isolated_filesystem) + + custom_name = "my_custom_bundle.supx" + result = cli_runner.invoke(app, ["bundle", "--output", custom_name]) + + assert result.exit_code == 0 + assert f"✅ Bundle created: {custom_name}" in result.output + + # Verify custom-named zip file was created + zip_path = isolated_filesystem / custom_name + assert_file_exists(zip_path) + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_with_output_directory( + mock_build, cli_runner, isolated_filesystem, extension_setup_for_bundling +): + """Test bundle command with output directory.""" + # Mock the build command + mock_build.return_value = None + + extension_setup_for_bundling(isolated_filesystem) + + # Create output directory + output_dir = isolated_filesystem / "output" + output_dir.mkdir() + + result = cli_runner.invoke(app, ["bundle", "--output", str(output_dir)]) + + assert result.exit_code == 0 + + # Verify zip file was created in output directory + expected_path = output_dir / "test_extension-1.0.0.supx" + assert_file_exists(expected_path) + assert f"✅ Bundle created: {expected_path}" in result.output + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_fails_without_manifest( + mock_build, cli_runner, isolated_filesystem +): + """Test bundle command fails when manifest.json doesn't exist.""" + # Mock build to succeed but not create manifest + mock_build.return_value = None + + # Create empty dist directory + (isolated_filesystem / "dist").mkdir() + + result = cli_runner.invoke(app, ["bundle"]) + + assert result.exit_code == 1 + assert "dist/manifest.json not found" in result.output + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_handles_zip_creation_error( + mock_build, cli_runner, isolated_filesystem, extension_setup_for_bundling +): + """Test bundle command handles zip file creation errors.""" + # Mock the build command + mock_build.return_value = None + + extension_setup_for_bundling(isolated_filesystem) + + # Try to bundle to an invalid location (directory that doesn't exist) + invalid_path = isolated_filesystem / "nonexistent" / "bundle.supx" + + with patch("zipfile.ZipFile", side_effect=OSError("Permission denied")): + result = cli_runner.invoke(app, ["bundle", "--output", str(invalid_path)]) + + assert result.exit_code == 1 + assert "Failed to create bundle" in result.output + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_includes_all_files_recursively( + mock_build, cli_runner, isolated_filesystem +): + """Test that bundle includes all files from dist directory recursively.""" + # Mock the build command + mock_build.return_value = None + + # Create complex dist structure + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir(parents=True) + + # Manifest + manifest = { + "id": "complex_extension", + "name": "Complex Extension", + "version": "2.1.0", + "permissions": [], + } + (dist_dir / "manifest.json").write_text(json.dumps(manifest)) + + # Frontend files with nested structure + frontend_dir = dist_dir / "frontend" / "dist" + frontend_dir.mkdir(parents=True) + (frontend_dir / "remoteEntry.xyz789.js").write_text("// entry") + + assets_dir = frontend_dir / "assets" + assets_dir.mkdir() + (assets_dir / "style.css").write_text("/* css */") + (assets_dir / "image.png").write_bytes(b"fake image data") + + # Backend files with nested structure + backend_dir = dist_dir / "backend" / "src" / "complex_extension" + backend_dir.mkdir(parents=True) + (backend_dir / "__init__.py").write_text("# init") + (backend_dir / "core.py").write_text("# core") + + utils_dir = backend_dir / "utils" + utils_dir.mkdir() + (utils_dir / "helpers.py").write_text("# helpers") + + result = cli_runner.invoke(app, ["bundle"]) + + assert result.exit_code == 0 + + # Verify zip file and contents + zip_path = isolated_filesystem / "complex_extension-2.1.0.supx" + assert_file_exists(zip_path) + + with zipfile.ZipFile(zip_path, "r") as zipf: + file_list = set(zipf.namelist()) + + # Verify all files are included + expected_files = { + "manifest.json", + "frontend/dist/remoteEntry.xyz789.js", + "frontend/dist/assets/style.css", + "frontend/dist/assets/image.png", + "backend/src/complex_extension/__init__.py", + "backend/src/complex_extension/core.py", + "backend/src/complex_extension/utils/helpers.py", + } + + assert expected_files.issubset(file_list), ( + f"Missing files: {expected_files - file_list}" + ) + + +@pytest.mark.cli +@patch("superset_cli.cli.build") +def test_bundle_command_short_option( + mock_build, cli_runner, isolated_filesystem, extension_setup_for_bundling +): + """Test bundle command with short -o option.""" + # Mock the build command + mock_build.return_value = None + + extension_setup_for_bundling(isolated_filesystem) + + result = cli_runner.invoke(app, ["bundle", "-o", "short_option.supx"]) + + assert result.exit_code == 0 + assert "✅ Bundle created: short_option.supx" in result.output + assert_file_exists(isolated_filesystem / "short_option.supx") + + +@pytest.mark.cli +@pytest.mark.parametrize("output_option", ["--output", "-o"]) +@patch("superset_cli.cli.build") +def test_bundle_command_output_options( + mock_build, + output_option, + cli_runner, + isolated_filesystem, + extension_setup_for_bundling, +): + """Test bundle command with both long and short output options.""" + # Mock the build command + mock_build.return_value = None + + extension_setup_for_bundling(isolated_filesystem) + + filename = f"test_{output_option.replace('-', '')}.supx" + result = cli_runner.invoke(app, ["bundle", output_option, filename]) + + assert result.exit_code == 0 + assert f"✅ Bundle created: {filename}" in result.output + assert_file_exists(isolated_filesystem / filename) diff --git a/superset-cli/tests/test_cli_dev.py b/superset-cli/tests/test_cli_dev.py new file mode 100644 index 00000000000..2d65468a63d --- /dev/null +++ b/superset-cli/tests/test_cli_dev.py @@ -0,0 +1,238 @@ +# 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 __future__ import annotations + +import json +import threading +import time +from unittest.mock import Mock, patch + +import pytest +from superset_cli.cli import app, FrontendChangeHandler + + +# Dev Command Tests +@pytest.mark.cli +@patch("superset_cli.cli.Observer") +@patch("superset_cli.cli.init_frontend_deps") +@patch("superset_cli.cli.rebuild_frontend") +@patch("superset_cli.cli.rebuild_backend") +@patch("superset_cli.cli.build_manifest") +@patch("superset_cli.cli.write_manifest") +def test_dev_command_starts_watchers( + mock_write_manifest, + mock_build_manifest, + mock_rebuild_backend, + mock_rebuild_frontend, + mock_init_frontend_deps, + mock_observer_class, + cli_runner, + isolated_filesystem, + extension_setup_for_dev, +): + """Test dev command starts file watchers.""" + # Setup mocks + mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" + mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"} + + mock_observer = Mock() + mock_observer_class.return_value = mock_observer + + extension_setup_for_dev(isolated_filesystem) + + # Run dev command in a thread since it's blocking + def run_dev(): + try: + cli_runner.invoke(app, ["dev"], catch_exceptions=False) + except KeyboardInterrupt: + pass + + dev_thread = threading.Thread(target=run_dev) + dev_thread.daemon = True + dev_thread.start() + + # Let it start up + time.sleep(0.1) + + # Verify observer methods were called + mock_observer.schedule.assert_called() + mock_observer.start.assert_called_once() + + # Initial setup calls + mock_init_frontend_deps.assert_called_once() + mock_rebuild_frontend.assert_called() + mock_rebuild_backend.assert_called() + mock_build_manifest.assert_called() + mock_write_manifest.assert_called() + + +@pytest.mark.cli +@patch("superset_cli.cli.init_frontend_deps") +@patch("superset_cli.cli.rebuild_frontend") +@patch("superset_cli.cli.rebuild_backend") +@patch("superset_cli.cli.build_manifest") +@patch("superset_cli.cli.write_manifest") +def test_dev_command_initial_build( + mock_write_manifest, + mock_build_manifest, + mock_rebuild_backend, + mock_rebuild_frontend, + mock_init_frontend_deps, + cli_runner, + isolated_filesystem, + extension_setup_for_dev, +): + """Test dev command performs initial build setup.""" + # Setup mocks + mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" + mock_build_manifest.return_value = {"name": "test", "version": "1.0.0"} + + extension_setup_for_dev(isolated_filesystem) + + with patch("superset_cli.cli.Observer") as mock_observer_class: + mock_observer = Mock() + mock_observer_class.return_value = mock_observer + + with patch("time.sleep", side_effect=KeyboardInterrupt): + try: + cli_runner.invoke(app, ["dev"], catch_exceptions=False) + except KeyboardInterrupt: + pass + + # Verify initial build steps + frontend_dir = isolated_filesystem / "frontend" + mock_init_frontend_deps.assert_called_once_with(frontend_dir) + mock_rebuild_frontend.assert_called_once_with(isolated_filesystem, frontend_dir) + mock_rebuild_backend.assert_called_once_with(isolated_filesystem) + + +# FrontendChangeHandler Tests +@pytest.mark.unit +def test_frontend_change_handler_init(): + """Test FrontendChangeHandler initialization.""" + mock_trigger = Mock() + handler = FrontendChangeHandler(trigger_build=mock_trigger) + + assert handler.trigger_build == mock_trigger + + +@pytest.mark.unit +def test_frontend_change_handler_ignores_dist_changes(): + """Test FrontendChangeHandler ignores changes in dist directory.""" + mock_trigger = Mock() + handler = FrontendChangeHandler(trigger_build=mock_trigger) + + # Create mock event with dist path + mock_event = Mock() + mock_event.src_path = "/path/to/frontend/dist/file.js" + + handler.on_any_event(mock_event) + + # Should not trigger build for dist changes + mock_trigger.assert_not_called() + + +@pytest.mark.unit +@pytest.mark.parametrize( + "source_path", + [ + "/path/to/frontend/src/component.tsx", + "/path/to/frontend/webpack.config.js", + "/path/to/frontend/package.json", + ], +) +def test_frontend_change_handler_triggers_on_source_changes(source_path): + """Test FrontendChangeHandler triggers build on source changes.""" + mock_trigger = Mock() + handler = FrontendChangeHandler(trigger_build=mock_trigger) + + # Create mock event with source path + mock_event = Mock() + mock_event.src_path = source_path + + handler.on_any_event(mock_event) + + # Should trigger build for source changes + mock_trigger.assert_called_once() + + +# Dev Utility Functions Tests +@pytest.mark.unit +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", + "version": "1.0.0", + "permissions": [], + } + (isolated_filesystem / "extension.json").write_text(json.dumps(extension_json)) + + # Create dist directory + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir() + + with patch("superset_cli.cli.rebuild_frontend") as mock_rebuild: + with patch("superset_cli.cli.build_manifest") as mock_build: + with patch("superset_cli.cli.write_manifest") as mock_write: + mock_rebuild.return_value = "remoteEntry.abc123.js" + mock_build.return_value = {"name": "test", "version": "1.0.0"} + + # Simulate frontend watcher function logic + frontend_dir = isolated_filesystem / "frontend" + frontend_dir.mkdir() + + # Actually call the functions to simulate the frontend_watcher + if ( + remote_entry := mock_rebuild(isolated_filesystem, frontend_dir) + ) is not None: + manifest = mock_build(isolated_filesystem, remote_entry) + mock_write(isolated_filesystem, manifest) + + mock_rebuild.assert_called_once_with(isolated_filesystem, frontend_dir) + mock_build.assert_called_once_with( + isolated_filesystem, "remoteEntry.abc123.js" + ) + mock_write.assert_called_once_with( + isolated_filesystem, {"name": "test", "version": "1.0.0"} + ) + + +@pytest.mark.unit +def test_backend_watcher_function_coverage(isolated_filesystem): + """Test backend watcher function for coverage.""" + # Create dist directory with manifest + dist_dir = isolated_filesystem / "dist" + dist_dir.mkdir() + + manifest_data = {"name": "test", "version": "1.0.0"} + (dist_dir / "manifest.json").write_text(json.dumps(manifest_data)) + + with patch("superset_cli.cli.rebuild_backend") as mock_rebuild: + with patch("superset_cli.cli.write_manifest") as mock_write: + # Simulate backend watcher function + mock_rebuild(isolated_filesystem) + + manifest_path = dist_dir / "manifest.json" + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text()) + mock_write(isolated_filesystem, manifest) + + mock_rebuild.assert_called_once_with(isolated_filesystem) + mock_write.assert_called_once() diff --git a/superset-cli/tests/test_cli_init.py b/superset-cli/tests/test_cli_init.py new file mode 100644 index 00000000000..c8735dbe2b9 --- /dev/null +++ b/superset-cli/tests/test_cli_init.py @@ -0,0 +1,362 @@ +# 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 __future__ import annotations + +from pathlib import Path + +import pytest +from superset_cli.cli import app + +from tests.utils import ( + assert_directory_exists, + assert_directory_structure, + assert_file_exists, + assert_file_structure, + assert_json_content, + create_test_extension_structure, + load_json_file, +) + + +# Init Command Tests +@pytest.mark.cli +def test_init_creates_extension_with_both_frontend_and_backend( + cli_runner, isolated_filesystem, cli_input_both +): + """Test that init creates a complete extension with both frontend and backend.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + + assert result.exit_code == 0, f"Command failed with output: {result.output}" + assert ( + "🎉 Extension Test Extension (ID: test_extension) initialized" in result.output + ) + + # Verify directory structure + extension_path = isolated_filesystem / "test_extension" + assert_directory_exists(extension_path, "main extension directory") + + expected_structure = create_test_extension_structure( + isolated_filesystem, + "test_extension", + include_frontend=True, + include_backend=True, + ) + + # Check directories + assert_directory_structure(extension_path, expected_structure["expected_dirs"]) + + # Check files + assert_file_structure(extension_path, expected_structure["expected_files"]) + + +@pytest.mark.cli +def test_init_creates_extension_with_frontend_only( + cli_runner, isolated_filesystem, cli_input_frontend_only +): + """Test that init creates extension with only frontend components.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_frontend_only) + + assert result.exit_code == 0, f"Command failed with output: {result.output}" + + extension_path = isolated_filesystem / "test_extension" + assert_directory_exists(extension_path) + + # Should have frontend directory and package.json + assert_directory_exists(extension_path / "frontend") + assert_file_exists(extension_path / "frontend" / "package.json") + + # Should NOT have backend directory + backend_path = extension_path / "backend" + assert not backend_path.exists(), ( + "Backend directory should not exist for frontend-only extension" + ) + + +@pytest.mark.cli +def test_init_creates_extension_with_backend_only( + cli_runner, isolated_filesystem, cli_input_backend_only +): + """Test that init creates extension with only backend components.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_backend_only) + + assert result.exit_code == 0, f"Command failed with output: {result.output}" + + extension_path = isolated_filesystem / "test_extension" + assert_directory_exists(extension_path) + + # Should have backend directory and pyproject.toml + assert_directory_exists(extension_path / "backend") + assert_file_exists(extension_path / "backend" / "pyproject.toml") + + # Should NOT have frontend directory + frontend_path = extension_path / "frontend" + assert not frontend_path.exists(), ( + "Frontend directory should not exist for backend-only extension" + ) + + +@pytest.mark.cli +def test_init_creates_extension_with_neither_frontend_nor_backend( + cli_runner, isolated_filesystem, cli_input_neither +): + """Test that init creates minimal extension with neither frontend nor backend.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_neither) + + assert result.exit_code == 0, f"Command failed with output: {result.output}" + + extension_path = isolated_filesystem / "test_extension" + assert_directory_exists(extension_path) + + # Should only have extension.json + assert_file_exists(extension_path / "extension.json") + + # Should NOT have frontend or backend directories + assert not (extension_path / "frontend").exists() + assert not (extension_path / "backend").exists() + + +@pytest.mark.cli +@pytest.mark.parametrize( + "invalid_name,expected_error", + [ + ("test-extension", "must be alphanumeric"), + ("test extension", "must be alphanumeric"), + ("test.extension", "must be alphanumeric"), + ("test@extension", "must be alphanumeric"), + ("", "must be alphanumeric"), + ], +) +def test_init_validates_extension_name( + cli_runner, isolated_filesystem, invalid_name, expected_error +): + """Test that init validates extension names according to regex pattern.""" + cli_input = f"{invalid_name}\n0.1.0\nApache-2.0\ny\ny\n" + 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.""" + with cli_runner.isolated_filesystem(): + cli_input = f"{valid_id}\nTest Extension\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}" + ) + assert Path(valid_id).exists(), f"Directory for '{valid_id}' was not created" + + +@pytest.mark.cli +def test_init_fails_when_directory_already_exists( + cli_runner, isolated_filesystem, cli_input_both +): + """Test that init fails gracefully when target directory already exists.""" + # Create the directory first + existing_dir = isolated_filesystem / "test_extension" + existing_dir.mkdir() + + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + + assert result.exit_code == 1, "Command should fail when directory already exists" + assert "already exists" in result.output + + +@pytest.mark.cli +def test_extension_json_content_is_correct( + cli_runner, isolated_filesystem, cli_input_both +): + """Test that the generated extension.json has the correct content.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + assert result.exit_code == 0 + + 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", + "name": "Test Extension", + "version": "0.1.0", + "license": "Apache-2.0", + "permissions": [], + }, + ) + + # Load and verify more complex nested structures + content = load_json_file(extension_json_path) + + # Verify frontend section exists and has correct structure + assert "frontend" in content + frontend = content["frontend"] + assert "contributions" in frontend + assert "moduleFederation" in frontend + assert frontend["contributions"] == {"commands": [], "views": [], "menus": []} + assert frontend["moduleFederation"] == {"exposes": ["./index"]} + + # 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"] + + +@pytest.mark.cli +def test_frontend_package_json_content_is_correct( + cli_runner, isolated_filesystem, cli_input_both +): + """Test that the generated frontend/package.json has the correct content.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + assert result.exit_code == 0 + + 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", + "version": "0.1.0", + "license": "Apache-2.0", + }, + ) + + # Verify more complex structures + content = load_json_file(package_json_path) + assert "scripts" in content + assert "build" in content["scripts"] + assert "peerDependencies" in content + assert "@apache-superset/core" in content["peerDependencies"] + + +@pytest.mark.cli +def test_backend_pyproject_toml_is_created( + cli_runner, isolated_filesystem, cli_input_both +): + """Test that the generated backend/pyproject.toml file is created.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + assert result.exit_code == 0 + + 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 "0.1.0" in content + assert "Apache-2.0" in content + + +@pytest.mark.cli +def test_init_command_output_messages(cli_runner, isolated_filesystem, cli_input_both): + """Test that init command produces expected output messages.""" + result = cli_runner.invoke(app, ["init"], input=cli_input_both) + + assert result.exit_code == 0 + output = result.output + + # Check for expected success messages + assert "✅ Created extension.json" 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 + + +@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" + result = cli_runner.invoke(app, ["init"], input=cli_input) + + assert result.exit_code == 0 + + extension_path = isolated_filesystem / "my_extension" + extension_json_path = extension_path / "extension.json" + + assert_json_content( + extension_json_path, + { + "id": "my_extension", + "name": "My Extension", + "version": "2.1.0", + "license": "MIT", + }, + ) + + +@pytest.mark.integration +@pytest.mark.cli +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" + 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" + expected_structure = create_test_extension_structure( + isolated_filesystem, + "awesome_charts", + include_frontend=True, + include_backend=True, + ) + + # Comprehensive structure verification + assert_directory_structure(extension_path, expected_structure["expected_dirs"]) + assert_file_structure(extension_path, expected_structure["expected_files"]) + + # 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["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" + + pyproject_content = (extension_path / "backend" / "pyproject.toml").read_text() + assert "awesome_charts" in pyproject_content diff --git a/superset-cli/tests/test_cli_validate.py b/superset-cli/tests/test_cli_validate.py new file mode 100644 index 00000000000..b4ae5d3c691 --- /dev/null +++ b/superset-cli/tests/test_cli_validate.py @@ -0,0 +1,195 @@ +# 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 __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from superset_cli.cli import app, validate_npm + + +# Validate Command Tests +@pytest.mark.cli +def test_validate_command_success(cli_runner): + """Test validate command succeeds when npm is available and valid.""" + with patch("superset_cli.cli.validate_npm") as mock_validate: + result = cli_runner.invoke(app, ["validate"]) + + assert result.exit_code == 0 + assert "✅ Validation successful" in result.output + mock_validate.assert_called_once() + + +@pytest.mark.cli +def test_validate_command_calls_npm_validation(cli_runner): + """Test that validate command calls the npm validation function.""" + with patch("superset_cli.cli.validate_npm") as mock_validate: + cli_runner.invoke(app, ["validate"]) + mock_validate.assert_called_once() + + +# Validate NPM Function Tests +@pytest.mark.unit +@patch("shutil.which") +def test_validate_npm_fails_when_npm_not_on_path(mock_which): + """Test validate_npm fails when npm is not on PATH.""" + mock_which.return_value = None + + with pytest.raises(SystemExit) as exc_info: + validate_npm() + + assert exc_info.value.code == 1 + mock_which.assert_called_once_with("npm") + + +@pytest.mark.unit +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_fails_when_npm_command_fails(mock_run, mock_which): + """Test validate_npm fails when npm -v command fails.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=1, stderr="Command failed") + + with pytest.raises(SystemExit) as exc_info: + validate_npm() + + assert exc_info.value.code == 1 + + +@pytest.mark.unit +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_fails_when_version_too_low(mock_run, mock_which): + """Test validate_npm fails when npm version is below minimum.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=0, stdout="9.0.0\n", stderr="") + + with pytest.raises(SystemExit) as exc_info: + validate_npm() + + assert exc_info.value.code == 1 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "npm_version", + [ + "10.8.2", # Exact minimum version + "11.0.0", # Higher version + "10.9.0-alpha.1", # Pre-release version higher than minimum + ], +) +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_succeeds_with_valid_versions(mock_run, mock_which, npm_version): + """Test validate_npm succeeds when npm version is valid.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=0, stdout=f"{npm_version}\n", stderr="") + + # Should not raise SystemExit + validate_npm() + + +@pytest.mark.unit +@pytest.mark.parametrize( + "npm_version,should_pass", + [ + ("10.8.2", True), # Exact minimum version + ("10.8.1", False), # Slightly lower version + ("10.9.0-alpha.1", True), # Pre-release version higher than minimum + ("9.9.9", False), # Much lower version + ("11.0.0", True), # Much higher version + ], +) +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_version_comparison_edge_cases( + mock_run, mock_which, npm_version, should_pass +): + """Test npm version comparison with edge cases.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=0, stdout=f"{npm_version}\n", stderr="") + + if should_pass: + # Should not raise SystemExit + validate_npm() + else: + with pytest.raises(SystemExit): + validate_npm() + + +@pytest.mark.unit +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_handles_file_not_found_exception(mock_run, mock_which): + """Test validate_npm handles FileNotFoundError gracefully.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.side_effect = FileNotFoundError("Test error") + + with pytest.raises(SystemExit) as exc_info: + validate_npm() + + assert exc_info.value.code == 1 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "exception_type", + [ + OSError, + PermissionError, + ], +) +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_does_not_catch_other_subprocess_exceptions( + mock_run, mock_which, exception_type +): + """Test validate_npm does not catch OSError and PermissionError (they propagate up).""" + mock_which.return_value = "/usr/bin/npm" + mock_run.side_effect = exception_type("Test error") + + # These exceptions should propagate up, not be caught + with pytest.raises(exception_type): + validate_npm() + + +@pytest.mark.unit +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_with_malformed_version_output_raises_error(mock_run, mock_which): + """Test validate_npm raises ValueError with malformed version output.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=0, stdout="not-a-version\n", stderr="") + + # semver.compare will raise ValueError for malformed version + with pytest.raises(ValueError): + validate_npm() + + +@pytest.mark.unit +@patch("shutil.which") +@patch("subprocess.run") +def test_validate_npm_with_empty_version_output_raises_error(mock_run, mock_which): + """Test validate_npm raises ValueError with empty version output.""" + mock_which.return_value = "/usr/bin/npm" + mock_run.return_value = Mock(returncode=0, stdout="", stderr="") + + # semver.compare will raise ValueError for empty version + with pytest.raises(ValueError): + validate_npm() diff --git a/superset-cli/tests/test_templates.py b/superset-cli/tests/test_templates.py new file mode 100644 index 00000000000..99e812a2dd3 --- /dev/null +++ b/superset-cli/tests/test_templates.py @@ -0,0 +1,329 @@ +# 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 __future__ import annotations + +import json +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader + + +@pytest.fixture +def templates_dir(): + """Get the templates directory path.""" + return Path(__file__).parent.parent / "src" / "superset_cli" / "templates" + + +@pytest.fixture +def jinja_env(templates_dir): + """Create a Jinja2 environment for testing templates.""" + return Environment(loader=FileSystemLoader(templates_dir)) + + +@pytest.fixture +def template_context(): + """Default template context for testing.""" + return { + "id": "test_extension", + "name": "Test Extension", + "version": "0.1.0", + "license": "Apache-2.0", + "include_frontend": True, + "include_backend": True, + } + + +# Extension JSON Template Tests +@pytest.mark.unit +def test_extension_json_template_renders_with_both_frontend_and_backend( + jinja_env, template_context +): + """Test extension.json template renders correctly with both frontend and backend.""" + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(template_context) + + # Parse the rendered JSON to ensure it's valid + parsed = json.loads(rendered) + + # Verify basic fields + assert parsed["id"] == "test_extension" + assert parsed["name"] == "Test Extension" + assert parsed["version"] == "0.1.0" + assert parsed["license"] == "Apache-2.0" + assert parsed["permissions"] == [] + + # Verify frontend section exists + assert "frontend" in parsed + frontend = parsed["frontend"] + assert "contributions" in frontend + assert "moduleFederation" in frontend + assert frontend["contributions"] == {"commands": [], "views": [], "menus": []} + assert frontend["moduleFederation"] == {"exposes": ["./index"]} + + # 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"] + + +@pytest.mark.unit +@pytest.mark.parametrize( + "include_frontend,include_backend,expected_sections", + [ + (True, False, ["frontend"]), + (False, True, ["backend"]), + (False, False, []), + ], +) +def test_extension_json_template_renders_with_different_configurations( + jinja_env, template_context, include_frontend, include_backend, expected_sections +): + """Test extension.json template renders correctly with different configurations.""" + template_context["include_frontend"] = include_frontend + template_context["include_backend"] = include_backend + + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(template_context) + + parsed = json.loads(rendered) + + # Check for expected sections + for section in expected_sections: + assert section in parsed, f"Expected section '{section}' not found" + + # Check that unexpected sections are not present + all_sections = ["frontend", "backend"] + for section in all_sections: + if section not in expected_sections: + assert section not in parsed, f"Unexpected section '{section}' found" + + +# Frontend Package JSON Template Tests +@pytest.mark.unit +def test_frontend_package_json_template_renders_correctly(jinja_env, template_context): + """Test frontend/package.json template renders correctly.""" + template = jinja_env.get_template("frontend/package.json.j2") + rendered = template.render(template_context) + + parsed = json.loads(rendered) + + # Verify basic package info + assert parsed["name"] == "test_extension" + assert parsed["version"] == "0.1.0" + assert parsed["license"] == "Apache-2.0" + assert parsed["private"] is True + + # Verify scripts section + assert "scripts" in parsed + scripts = parsed["scripts"] + assert "start" in scripts + assert "build" in scripts + assert "webpack" in scripts["build"] + + # Verify dependencies + assert "peerDependencies" in parsed + peer_deps = parsed["peerDependencies"] + assert "@apache-superset/core" in peer_deps + assert "react" in peer_deps + assert "react-dom" in peer_deps + + # Verify dev dependencies + assert "devDependencies" in parsed + dev_deps = parsed["devDependencies"] + assert "webpack" in dev_deps + assert "typescript" in dev_deps + + +# Backend Pyproject TOML Template Tests +@pytest.mark.unit +def test_backend_pyproject_toml_template_renders_correctly(jinja_env, template_context): + """Test backend/pyproject.toml template renders correctly.""" + template = jinja_env.get_template("backend/pyproject.toml.j2") + rendered = template.render(template_context) + + # Basic content verification (without full TOML parsing) + assert "test_extension" in rendered + assert "0.1.0" in rendered + assert "Apache-2.0" in rendered + + +# Template Rendering with Different Parameters Tests +@pytest.mark.unit +@pytest.mark.parametrize( + "id_,name", + [ + ("simple_extension", "Simple Extension"), + ("MyExtension123", "My Extension 123"), + ("complex_extension_name_123", "Complex Extension Name 123"), + ("ext", "Ext"), + ], +) +def test_template_rendering_with_different_ids(jinja_env, id_, name): + """Test templates render correctly with various extension ids/names.""" + context = { + "id": id_, + "name": name, + "version": "1.0.0", + "license": "MIT", + "include_frontend": True, + "include_backend": True, + } + + # Test extension.json template + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(context) + parsed = json.loads(rendered) + + assert parsed["id"] == id_ + assert parsed["name"] == name + assert parsed["backend"]["entryPoints"] == [f"{id_}.entrypoint"] + assert parsed["backend"]["files"] == [f"backend/src/{id_}/**/*.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_ + + # Test pyproject.toml template + template = jinja_env.get_template("backend/pyproject.toml.j2") + rendered = template.render(context) + + assert id_ in rendered + + +@pytest.mark.unit +@pytest.mark.parametrize("version", ["0.1.0", "1.0.0", "2.1.3-alpha", "10.20.30"]) +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", + "version": version, + "license": "Apache-2.0", + "include_frontend": True, + "include_backend": False, + } + + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(context) + parsed = json.loads(rendered) + + assert parsed["version"] == version + + +@pytest.mark.unit +@pytest.mark.parametrize( + "license_type", + [ + "Apache-2.0", + "MIT", + "BSD-3-Clause", + "GPL-3.0", + "Custom License", + ], +) +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", + "version": "1.0.0", + "license": license_type, + "include_frontend": True, + "include_backend": True, + } + + # Test extension.json template + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(context) + parsed = json.loads(rendered) + + assert parsed["license"] == license_type + + # Test package.json template + template = jinja_env.get_template("frontend/package.json.j2") + rendered = template.render(context) + parsed = json.loads(rendered) + + assert parsed["license"] == license_type + + +# Template Validation Tests +@pytest.mark.unit +@pytest.mark.parametrize( + "template_name", ["extension.json.j2", "frontend/package.json.j2"] +) +def test_templates_produce_valid_json(jinja_env, template_context, template_name): + """Test that all JSON templates produce valid JSON output.""" + template = jinja_env.get_template(template_name) + rendered = template.render(template_context) + + # This will raise an exception if the JSON is invalid + try: + json.loads(rendered) + except json.JSONDecodeError as e: + pytest.fail(f"Template {template_name} produced invalid JSON: {e}") + + +@pytest.mark.unit +def test_template_whitespace_handling(jinja_env, template_context): + """Test that templates handle whitespace correctly and produce clean output.""" + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(template_context) + + # Should not have excessive empty lines + lines = rendered.split("\n") + empty_line_count = sum(1 for line in lines if line.strip() == "") + + # Some empty lines are OK for formatting, but not excessive + assert empty_line_count < len(lines) / 2, ( + "Too many empty lines in rendered template" + ) + + # Should be properly formatted JSON + parsed = json.loads(rendered) + # Re-serialize to check it's valid structure + json.dumps(parsed, indent=2) + + +@pytest.mark.unit +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", + "version": "1.0.0", + "license": "MIT", + "include_frontend": False, + "include_backend": False, + } + + template = jinja_env.get_template("extension.json.j2") + rendered = template.render(minimal_context) + parsed = json.loads(rendered) + + # Should still be valid JSON with basic fields + assert parsed["id"] == "minimal" + assert parsed["name"] == "Minimal" + assert "frontend" not in parsed + assert "backend" not in parsed diff --git a/superset-cli/tests/test_utils.py b/superset-cli/tests/test_utils.py new file mode 100644 index 00000000000..492ec35113a --- /dev/null +++ b/superset-cli/tests/test_utils.py @@ -0,0 +1,271 @@ +# 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 __future__ import annotations + +import json + +import pytest +from superset_cli.utils import read_json, read_toml + + +# Read JSON Tests +@pytest.mark.unit +def test_read_json_with_valid_file(isolated_filesystem): + """Test read_json with valid JSON file.""" + json_data = {"name": "test", "version": "1.0.0"} + json_file = isolated_filesystem / "test.json" + json_file.write_text(json.dumps(json_data)) + + result = read_json(json_file) + + assert result == json_data + + +@pytest.mark.unit +def test_read_json_with_nonexistent_file(isolated_filesystem): + """Test read_json returns None when file doesn't exist.""" + nonexistent_file = isolated_filesystem / "nonexistent.json" + + result = read_json(nonexistent_file) + + assert result is None + + +@pytest.mark.unit +def test_read_json_with_invalid_json(isolated_filesystem): + """Test read_json with invalid JSON content.""" + invalid_json_file = isolated_filesystem / "invalid.json" + invalid_json_file.write_text("{ invalid json content") + + with pytest.raises(json.JSONDecodeError): + read_json(invalid_json_file) + + +@pytest.mark.unit +def test_read_json_with_directory_instead_of_file(isolated_filesystem): + """Test read_json returns None when path is a directory.""" + directory = isolated_filesystem / "test_dir" + directory.mkdir() + + result = read_json(directory) + + assert result is None + + +@pytest.mark.unit +@pytest.mark.parametrize( + "json_content,expected", + [ + ({"simple": "value"}, {"simple": "value"}), + ({"nested": {"key": "value"}}, {"nested": {"key": "value"}}), + ({"array": [1, 2, 3]}, {"array": [1, 2, 3]}), + ({}, {}), # Empty JSON object + ], +) +def test_read_json_with_various_valid_content( + isolated_filesystem, json_content, expected +): + """Test read_json with various valid JSON content types.""" + json_file = isolated_filesystem / "test.json" + json_file.write_text(json.dumps(json_content)) + + result = read_json(json_file) + + assert result == expected + + +# Read TOML Tests +@pytest.mark.unit +def test_read_toml_with_valid_file(isolated_filesystem): + """Test read_toml with valid TOML file.""" + toml_content = '[project]\nname = "test"\nversion = "1.0.0"' + toml_file = isolated_filesystem / "pyproject.toml" + toml_file.write_text(toml_content) + + result = read_toml(toml_file) + + assert result is not None + assert result["project"]["name"] == "test" + assert result["project"]["version"] == "1.0.0" + + +@pytest.mark.unit +def test_read_toml_with_nonexistent_file(isolated_filesystem): + """Test read_toml returns None when file doesn't exist.""" + nonexistent_file = isolated_filesystem / "nonexistent.toml" + + result = read_toml(nonexistent_file) + + assert result is None + + +@pytest.mark.unit +def test_read_toml_with_directory_instead_of_file(isolated_filesystem): + """Test read_toml returns None when path is a directory.""" + directory = isolated_filesystem / "test_dir" + directory.mkdir() + + result = read_toml(directory) + + assert result is None + + +@pytest.mark.unit +def test_read_toml_with_invalid_toml(isolated_filesystem): + """Test read_toml with invalid TOML content.""" + invalid_toml_file = isolated_filesystem / "invalid.toml" + invalid_toml_file.write_text("[ invalid toml content") + + with pytest.raises(Exception): # tomli raises various exceptions for invalid TOML + read_toml(invalid_toml_file) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "toml_content,expected_keys", + [ + ('[project]\nname = "test"', ["project"]), + ('[build-system]\nrequires = ["setuptools"]', ["build-system"]), + ( + '[project]\nname = "test"\n[build-system]\nrequires = ["setuptools"]', + ["project", "build-system"], + ), + ], +) +def test_read_toml_with_various_valid_content( + isolated_filesystem, toml_content, expected_keys +): + """Test read_toml with various valid TOML content types.""" + toml_file = isolated_filesystem / "test.toml" + toml_file.write_text(toml_content) + + result = read_toml(toml_file) + + assert result is not None + for key in expected_keys: + assert key in result + + +@pytest.mark.unit +def test_read_toml_with_complex_structure(isolated_filesystem): + """Test read_toml with complex TOML structure.""" + complex_toml = """ +[project] +name = "my-package" +version = "1.0.0" +authors = [ + {name = "Author Name", email = "author@example.com"} +] + +[project.dependencies] +requests = "^2.25.0" + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" +""" + toml_file = isolated_filesystem / "complex.toml" + toml_file.write_text(complex_toml) + + result = read_toml(toml_file) + + assert result is not None + assert result["project"]["name"] == "my-package" + assert result["project"]["version"] == "1.0.0" + assert len(result["project"]["authors"]) == 1 + assert result["project"]["authors"][0]["name"] == "Author Name" + assert result["build-system"]["requires"] == ["setuptools", "wheel"] + + +@pytest.mark.unit +def test_read_toml_with_empty_file(isolated_filesystem): + """Test read_toml with empty TOML file.""" + toml_file = isolated_filesystem / "empty.toml" + toml_file.write_text("") + + result = read_toml(toml_file) + + assert result == {} + + +@pytest.mark.unit +@pytest.mark.parametrize( + "invalid_content", + [ + "[ invalid section", + "key = ", + "key = unquoted string", + "[section\nkey = value", + ], +) +def test_read_toml_with_various_invalid_content(isolated_filesystem, invalid_content): + """Test read_toml with various types of invalid TOML content.""" + toml_file = isolated_filesystem / "invalid.toml" + toml_file.write_text(invalid_content) + + with pytest.raises(Exception): # Various TOML parsing exceptions + read_toml(toml_file) + + +# File System Edge Cases +@pytest.mark.unit +def test_read_json_with_permission_denied(isolated_filesystem): + """Test read_json behavior when file permissions are denied.""" + json_file = isolated_filesystem / "restricted.json" + json_file.write_text('{"test": "value"}') + + # This test may not work on all systems, so we'll skip it if chmod doesn't work + try: + json_file.chmod(0o000) # No permissions + result = read_json(json_file) + # If we get here without exception, the file was still readable + # This is system-dependent behavior + assert result is None or result == {"test": "value"} + except (OSError, PermissionError): + # Expected on some systems + pass + finally: + # Restore permissions for cleanup + try: + json_file.chmod(0o644) + except (OSError, PermissionError): + pass + + +@pytest.mark.unit +def test_read_toml_with_permission_denied(isolated_filesystem): + """Test read_toml behavior when file permissions are denied.""" + toml_file = isolated_filesystem / "restricted.toml" + toml_file.write_text('[test]\nkey = "value"') + + # This test may not work on all systems, so we'll skip it if chmod doesn't work + try: + toml_file.chmod(0o000) # No permissions + result = read_toml(toml_file) + # If we get here without exception, the file was still readable + # This is system-dependent behavior + assert result is None or "test" in result + except (OSError, PermissionError): + # Expected on some systems + pass + finally: + # Restore permissions for cleanup + try: + toml_file.chmod(0o644) + except (OSError, PermissionError): + pass diff --git a/superset-cli/tests/utils.py b/superset-cli/tests/utils.py new file mode 100644 index 00000000000..77513bcb855 --- /dev/null +++ b/superset-cli/tests/utils.py @@ -0,0 +1,211 @@ +# 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 __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def assert_file_exists(path: Path, description: str = "") -> None: + """ + Assert that a file exists with a descriptive error message. + + Args: + path: Path to the file that should exist + description: Optional description for better error messages + """ + desc_msg = f" ({description})" if description else "" + assert path.exists(), f"Expected file {path}{desc_msg} to exist, but it doesn't" + assert path.is_file(), f"Expected {path}{desc_msg} to be a file, but it's not" + + +def assert_directory_exists(path: Path, description: str = "") -> None: + """ + Assert that a directory exists with a descriptive error message. + + Args: + path: Path to the directory that should exist + description: Optional description for better error messages + """ + desc_msg = f" ({description})" if description else "" + assert path.exists(), ( + f"Expected directory {path}{desc_msg} to exist, but it doesn't" + ) + assert path.is_dir(), f"Expected {path}{desc_msg} to be a directory, but it's not" + + +def assert_file_structure(base_path: Path, expected_files: list[str]) -> None: + """ + Assert that all expected files exist under the base path. + + Args: + base_path: Base directory path + expected_files: List of relative file paths that should exist + """ + for file_path in expected_files: + full_path = base_path / file_path + assert_file_exists(full_path, "part of expected structure") + + +def assert_directory_structure(base_path: Path, expected_dirs: list[str]) -> None: + """ + Assert that all expected directories exist under the base path. + + Args: + base_path: Base directory path + expected_dirs: List of relative directory paths that should exist + """ + for dir_path in expected_dirs: + full_path = base_path / dir_path + assert_directory_exists(full_path, "part of expected structure") + + +def get_directory_tree(path: Path, ignore: set[str] | None = None) -> set[str]: + """ + Get all files and directories under a path as relative string paths. + + Args: + path: Base path to scan + ignore: Set of file/directory names to ignore + + Returns: + Set of relative path strings + """ + ignore = ignore or {".DS_Store", "__pycache__", ".pytest_cache"} + tree: set[str] = set() + + if not path.exists(): + return tree + + for item in path.rglob("*"): + if any(ignored in item.parts for ignored in ignore): + continue + relative = item.relative_to(path) + tree.add(str(relative)) + + return tree + + +def load_json_file(path: Path) -> dict[str, Any]: + """ + Load and parse a JSON file. + + Args: + path: Path to the JSON file + + Returns: + Parsed JSON content + + Raises: + AssertionError: If file doesn't exist or isn't valid JSON + """ + assert_file_exists(path, "JSON file") + try: + content = json.loads(path.read_text()) + return content + except json.JSONDecodeError as e: + raise AssertionError(f"File {path} contains invalid JSON: {e}") + + +def assert_json_content(path: Path, expected_values: dict[str, Any]) -> None: + """ + Assert that a JSON file contains expected key-value pairs. + + Args: + path: Path to the JSON file + expected_values: Dictionary of expected key-value pairs + """ + content = load_json_file(path) + + for key, expected_value in expected_values.items(): + assert key in content, f"Expected key '{key}' not found in {path}" + actual_value = content[key] + assert actual_value == expected_value, ( + f"Expected {key}='{expected_value}' but got '{actual_value}' in {path}" + ) + + +def assert_file_contains(path: Path, text: str) -> None: + """ + Assert that a file contains specific text. + + Args: + path: Path to the file + text: Text that should be present in the file + """ + assert_file_exists(path, "text file") + content = path.read_text() + assert text in content, f"Expected text '{text}' not found in {path}" + + +def assert_file_content_matches(path: Path, expected_content: str) -> None: + """ + Assert that a file's content exactly matches expected content. + + Args: + path: Path to the file + expected_content: Expected file content + """ + assert_file_exists(path, "content file") + actual_content = path.read_text() + assert actual_content == expected_content, ( + f"File content mismatch in {path}\n" + f"Expected:\n{expected_content}\n" + f"Actual:\n{actual_content}" + ) + + +def create_test_extension_structure( + base_path: Path, + id_: str, + include_frontend: bool = True, + include_backend: bool = True, +) -> dict[str, Any]: + """ + Helper to create expected extension structure for testing. + + Args: + base_path: Base path where extension should be created + id_: Unique identifier for extension + name: Extension name + include_frontend: Whether frontend should be included + include_backend: Whether backend should be included + + Returns: + Dictionary with expected paths and metadata + """ + extension_path = base_path / id_ + expected_files = ["extension.json"] + expected_dirs: list[str] = [] + + if include_frontend: + expected_dirs.append("frontend") + expected_files.append("frontend/package.json") + + if include_backend: + expected_dirs.append("backend") + expected_files.append("backend/pyproject.toml") + + expected = { + "extension_path": extension_path, + "expected_files": expected_files, + "expected_dirs": expected_dirs, + } + + return expected diff --git a/superset-core/.gitignore b/superset-core/.gitignore new file mode 100644 index 00000000000..e0a1eee298f --- /dev/null +++ b/superset-core/.gitignore @@ -0,0 +1 @@ +apache_superset_primitives.egg-info/ diff --git a/superset-core/LICENSE.txt b/superset-core/LICENSE.txt new file mode 100644 index 00000000000..56313ab5a54 --- /dev/null +++ b/superset-core/LICENSE.txt @@ -0,0 +1,216 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed 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. + +============================================================================ + APACHE SUPERSET SUBCOMPONENTS: + + The Apache Superset project contains subcomponents with separate copyright + notices and license terms. Your use of the source code for the these + subcomponents is subject to the terms and conditions of the following + licenses. + +======================================================================== +Third party SIL Open Font License v1.1 (OFL-1.1) +======================================================================== + +(SIL OPEN FONT LICENSE Version 1.1) The Inter font family (https://github.com/rsms/inter) +(SIL OPEN FONT LICENSE Version 1.1) The Fira Code font family (https://github.com/tonsky/FiraCode) diff --git a/superset-core/pyproject.toml b/superset-core/pyproject.toml new file mode 100644 index 00000000000..692e157fbee --- /dev/null +++ b/superset-core/pyproject.toml @@ -0,0 +1,42 @@ + +# 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. + +[project] +name = "apache-superset-core" +version = "0.0.1" +description = "Common components for Apache Superset" +authors = [ + { name = "Apache Software Foundation", email = "dev@superset.apache.org" }, +] +license = { file="LICENSE.txt" } +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "flask-appbuilder>=4.5.3, <5.0.0", +] + +[build-system] +requires = ["setuptools>=76.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["superset_core"] +package-dir = { "" = "src" } diff --git a/superset-core/src/superset_core/__init__.py b/superset-core/src/superset_core/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-core/src/superset_core/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-core/src/superset_core/api/__init__.py b/superset-core/src/superset_core/api/__init__.py new file mode 100644 index 00000000000..70a9d4080ea --- /dev/null +++ b/superset-core/src/superset_core/api/__init__.py @@ -0,0 +1,24 @@ +# 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 .types.models import CoreModelsApi +from .types.query import CoreQueryApi +from .types.rest_api import CoreRestApi + +models: CoreModelsApi +rest_api: CoreRestApi +query: CoreQueryApi diff --git a/superset-core/src/superset_core/api/types/__init__.py b/superset-core/src/superset_core/api/types/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-core/src/superset_core/api/types/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-core/src/superset_core/api/types/models.py b/superset-core/src/superset_core/api/types/models.py new file mode 100644 index 00000000000..2adbddf3499 --- /dev/null +++ b/superset-core/src/superset_core/api/types/models.py @@ -0,0 +1,90 @@ +# 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 abc import ABC, abstractmethod +from typing import Any, Type + +from flask_sqlalchemy import BaseQuery +from sqlalchemy.orm import scoped_session + + +class CoreModelsApi(ABC): + """ + Abstract interface for accessing Superset data models. + + This class defines the contract for retrieving SQLAlchemy sessions + and model instances for datasets and databases within Superset. + """ + + @staticmethod + @abstractmethod + def get_session() -> scoped_session: + """ + Retrieve the SQLAlchemy session to directly interface with the + Superset models. + + :returns: The SQLAlchemy scoped session instance. + """ + ... + + @staticmethod + @abstractmethod + def get_dataset_model() -> Type[Any]: + """ + Retrieve the Dataset (SqlaTable) SQLAlchemy model. + + :returns: The Dataset SQLAlchemy model class. + """ + ... + + @staticmethod + @abstractmethod + def get_database_model() -> Type[Any]: + """ + Retrieve the Database SQLAlchemy model. + + :returns: The Database SQLAlchemy model class. + """ + ... + + @staticmethod + @abstractmethod + def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: + """ + Retrieve Dataset (SqlaTable) entities. + + :param query: A query with the Dataset model as the primary entity for complex + queries. + :param kwargs: Optional keyword arguments to filter datasets using SQLAlchemy's + `filter_by()`. + :returns: SqlaTable entities. + """ + ... + + @staticmethod + @abstractmethod + def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: + """ + Retrieve Database entities. + + :param query: A query with the Database model as the primary entity for complex + queries. + :param kwargs: Optional keyword arguments to filter databases using SQLAlchemy's + `filter_by()`. + :returns: Database entities. + """ + ... diff --git a/superset-core/src/superset_core/api/types/query.py b/superset-core/src/superset_core/api/types/query.py new file mode 100644 index 00000000000..28b78a7352a --- /dev/null +++ b/superset-core/src/superset_core/api/types/query.py @@ -0,0 +1,41 @@ +# 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 abc import ABC, abstractmethod +from typing import Any + +from sqlglot import Dialects + + +class CoreQueryApi(ABC): + """ + Abstract interface for query-related operations. + + This class defines the contract for database query operations, + including dialect handling and query processing. + """ + + @staticmethod + @abstractmethod + def get_sqlglot_dialect(database: Any) -> Dialects: + """ + Get the SQLGlot dialect for the specified database. + + :param database: The database instance to get the dialect for. + :returns: The SQLGlot dialect enum corresponding to the database. + """ + ... diff --git a/superset-core/src/superset_core/api/types/rest_api.py b/superset-core/src/superset_core/api/types/rest_api.py new file mode 100644 index 00000000000..a451c02c3ca --- /dev/null +++ b/superset-core/src/superset_core/api/types/rest_api.py @@ -0,0 +1,64 @@ +# 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 abc import ABC, abstractmethod +from typing import Type + +from flask_appbuilder.api import BaseApi + + +class RestApi(BaseApi): + """ + Base REST API class for Superset with browser login support. + + This class extends Flask-AppBuilder's BaseApi and enables browser-based + authentication by default. + """ + + allow_browser_login = True + + +class CoreRestApi(ABC): + """ + Abstract interface for managing REST APIs in Superset. + + This class defines the contract for adding and managing REST APIs, + including both core APIs and extension APIs. + """ + + @staticmethod + @abstractmethod + def add_api(api: Type[RestApi]) -> None: + """ + Add a REST API to the Superset API. + + :param api: A REST API instance. + :returns: None. + """ + ... + + @staticmethod + @abstractmethod + def add_extension_api(api: Type[RestApi]) -> None: + """ + Add an extension REST API to the Superset API. + + :param api: An extension REST API instance. These are placed under + the /extensions resource. + :returns: None. + """ + ... diff --git a/superset-core/src/superset_core/extensions/__init__.py b/superset-core/src/superset_core/extensions/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset-core/src/superset_core/extensions/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset-core/src/superset_core/extensions/types.py b/superset-core/src/superset_core/extensions/types.py new file mode 100644 index 00000000000..bb869944d1f --- /dev/null +++ b/superset-core/src/superset_core/extensions/types.py @@ -0,0 +1,63 @@ +# 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 ModuleFederationConfig(TypedDict): + exposes: dict[str, str] + filename: str + shared: dict[str, str] + remotes: dict[str, str] + + +class FrontendContributionConfig(TypedDict): + commands: dict[str, list[dict[str, str]]] + views: dict[str, list[dict[str, str]]] + menus: dict[str, list[dict[str, str]]] + + +class FrontendManifest(TypedDict): + contributions: FrontendContributionConfig + moduleFederation: ModuleFederationConfig + remoteEntry: str + + +class BackendManifest(TypedDict): + entryPoints: list[str] + + +class SharedBase(TypedDict, total=False): + id: str + name: str + dependencies: list[str] + description: str + version: str + frontend: FrontendManifest + permissions: list[str] + + +class Manifest(SharedBase, total=False): + backend: BackendManifest + + +class BackendMetadata(BackendManifest): + files: list[str] + + +class Metadata(SharedBase): + backend: BackendMetadata diff --git a/superset-frontend/babel.config.js b/superset-frontend/babel.config.js index 7ac0abcbd9a..fc1acf1bab4 100644 --- a/superset-frontend/babel.config.js +++ b/superset-frontend/babel.config.js @@ -46,6 +46,7 @@ module.exports = { plugins: [ 'lodash', '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-export-namespace-from', ['@babel/plugin-transform-class-properties', { loose: true }], ['@babel/plugin-transform-optional-chaining', { loose: true }], ['@babel/plugin-transform-private-methods', { loose: true }], @@ -89,6 +90,7 @@ module.exports = { plugins: [ 'babel-plugin-dynamic-import-node', '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-transform-export-namespace-from', ], }, // build instrumented code for testing code coverage with Cypress diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index 43edcc56384..3062fe4e5bf 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -31,6 +31,8 @@ module.exports = { '^@superset-ui/([^/]+)/(.*)$': '/node_modules/@superset-ui/$1/src/$2', '^@superset-ui/([^/]+)$': '/node_modules/@superset-ui/$1/src', + // mapping @apache-superset/core to local package + '^@apache-superset/core$': '/packages/superset-core/src', }, testEnvironment: '/spec/helpers/jsDomWithFetchAPI.ts', modulePathIgnorePatterns: ['/packages/generator-superset'], diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 76d27774532..0f5538f1f4d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -14,6 +14,7 @@ "src/setup/*" ], "dependencies": { + "@apache-superset/core": "file:packages/superset-core", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -142,6 +143,7 @@ "@babel/eslint-parser": "^7.25.9", "@babel/node": "^7.22.6", "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-runtime": "^7.27.1", "@babel/preset-env": "^7.27.2", @@ -445,6 +447,10 @@ "react": ">=16.9.0" } }, + "node_modules/@apache-superset/core": { + "resolved": "packages/superset-core", + "link": true + }, "node_modules/@applitools/core": { "version": "4.40.0", "resolved": "https://registry.npmjs.org/@applitools/core/-/core-4.40.0.tgz", @@ -31140,6 +31146,16 @@ "node": ">=8" } }, + "node_modules/install": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", + "integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -53586,6 +53602,10 @@ "kdbush": "^4.0.2" } }, + "node_modules/superset-core": { + "resolved": "packages/superset-core", + "link": true + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -60579,6 +60599,2698 @@ } } }, + "packages/superset-core": { + "version": "0.0.1", + "license": "ISC", + "devDependencies": { + "@babel/cli": "^7.26.4", + "@babel/core": "^7.26.9", + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@types/react": "^17.0.83", + "install": "^0.13.0", + "npm": "^11.1.0" + }, + "peerDependencies": { + "antd": "^5.24.6", + "react": "^17.0.2" + } + }, + "packages/superset-core/node_modules/npm": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.5.2.tgz", + "integrity": "sha512-qsEkHPw/Qdw4eA1kKVxsa5F6QeJCiLM1GaexGt/FpUpfiBxkLXVXIVtscOAeVWVe17pmYwD9Aji8dfsXR4r68w==", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "dev": true, + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.1.3", + "@npmcli/config": "^10.3.1", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.2.0", + "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/redact": "^3.2.2", + "@npmcli/run-script": "^9.1.0", + "@sigstore/tuf": "^3.1.1", + "abbrev": "^3.0.1", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.4.1", + "ci-info": "^4.3.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.5", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.1.0", + "ini": "^5.0.0", + "init-package-json": "^8.2.1", + "is-cidr": "^5.1.1", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^10.0.1", + "libnpmdiff": "^8.0.6", + "libnpmexec": "^10.1.5", + "libnpmfund": "^7.0.6", + "libnpmorg": "^8.0.0", + "libnpmpack": "^9.0.6", + "libnpmpublish": "^11.1.0", + "libnpmsearch": "^9.0.0", + "libnpmteam": "^8.0.1", + "libnpmversion": "^8.0.1", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.5", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.2.0", + "nopt": "^8.1.0", + "normalize-package-data": "^7.0.1", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.1", + "npm-package-arg": "^12.0.2", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^7.0.3", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.1.0", + "semver": "^7.7.2", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^10.0.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.2", + "which": "^5.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.1.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^9.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/config": { + "version": "10.3.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.1.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.2.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.2.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/bundle": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/core": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.4.3", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/sign": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.1", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@sigstore/verify": { + "version": "2.1.1", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/@tufjs/models": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/abbrev": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/agent-base": { + "version": "7.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/aproba": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/binary-extensions": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/superset-core/node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache/node_modules/minizlib": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/chalk": { + "version": "5.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ci-info": { + "version": "4.3.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.3", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "packages/superset-core/node_modules/npm/node_modules/debug": { + "version": "4.4.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/superset-core/node_modules/npm/node_modules/diff": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "packages/superset-core/node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "packages/superset-core/node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.2", + "dev": true, + "inBundle": true, + "license": "Apache-2.0" + }, + "packages/superset-core/node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/foreground-child": { + "version": "3.3.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/glob": { + "version": "10.4.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/hosted-git-info": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause" + }, + "packages/superset-core/node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/init-package-json": { + "version": "8.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.1.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/superset-core/node_modules/npm/node_modules/is-cidr": { + "version": "5.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmdiff": { + "version": "8.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.1.3", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^3.0.0", + "diff": "^7.0.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "tar": "^6.2.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmexec": { + "version": "10.1.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.1.3", + "@npmcli/package-json": "^6.1.1", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.1.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmpack": { + "version": "9.0.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.1.3", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmpublish": { + "version": "11.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.2.0", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "dev": true, + "inBundle": true, + "license": "ISC" + }, + "packages/superset-core/node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minimatch": { + "version": "9.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp": { + "version": "11.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/nopt": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.1", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "packages/superset-core/node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/p-map": { + "version": "7.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/superset-core/node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "packages/superset-core/node_modules/npm/node_modules/pacote": { + "version": "21.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^10.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "packages/superset-core/node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "packages/superset-core/node_modules/npm/node_modules/read": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "packages/superset-core/node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true + }, + "packages/superset-core/node_modules/npm/node_modules/semver": { + "version": "7.7.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/superset-core/node_modules/npm/node_modules/sigstore": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/socks": { + "version": "2.8.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/superset-core/node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "inBundle": true, + "license": "CC-BY-3.0" + }, + "packages/superset-core/node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.21", + "dev": true, + "inBundle": true, + "license": "CC0-1.0" + }, + "packages/superset-core/node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "dev": true, + "inBundle": true, + "license": "BSD-3-Clause" + }, + "packages/superset-core/node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/supports-color": { + "version": "10.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "packages/superset-core/node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.14", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "dev": true, + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "packages/superset-core/node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/superset-core/node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/tuf-js": { + "version": "3.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.2", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "packages/superset-core/node_modules/npm/node_modules/which": { + "version": "5.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "dev": true, + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "dev": true, + "inBundle": true, + "license": "MIT" + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/superset-core/node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "packages/superset-core/node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "dev": true, + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "packages/superset-core/node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "inBundle": true, + "license": "ISC" + }, "packages/superset-ui-chart-controls": { "name": "@superset-ui/chart-controls", "version": "0.20.3", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 5b24a318580..9aac8098038 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -82,6 +82,7 @@ "last 3 edge versions" ], "dependencies": { + "@apache-superset/core": "file:packages/superset-core", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", @@ -210,6 +211,7 @@ "@babel/eslint-parser": "^7.25.9", "@babel/node": "^7.22.6", "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.26.3", "@babel/plugin-transform-runtime": "^7.27.1", "@babel/preset-env": "^7.27.2", diff --git a/superset-frontend/packages/superset-core/.babelrc.json b/superset-frontend/packages/superset-core/.babelrc.json new file mode 100644 index 00000000000..202d425a099 --- /dev/null +++ b/superset-frontend/packages/superset-core/.babelrc.json @@ -0,0 +1,7 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript" + ] +} diff --git a/superset-frontend/packages/superset-core/package.json b/superset-frontend/packages/superset-core/package.json new file mode 100644 index 00000000000..ab20221d0a3 --- /dev/null +++ b/superset-frontend/packages/superset-core/package.json @@ -0,0 +1,35 @@ +{ + "name": "superset-core", + "version": "0.0.1", + "description": "This package contains UI elements, APIs, and utility functions used by Superset.", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/cli": "^7.26.4", + "@babel/core": "^7.26.9", + "@babel/preset-env": "^7.26.9", + "@babel/preset-react": "^7.26.3", + "@babel/preset-typescript": "^7.26.0", + "@types/react": "^17.0.83", + "install": "^0.13.0", + "npm": "^11.1.0" + }, + "peerDependencies": { + "antd": "^5.24.6", + "react": "^17.0.2" + }, + "scripts": { + "build": "babel src --out-dir lib --extensions \".ts,.tsx\"", + "type": "tsc --noEmit" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/superset-frontend/packages/superset-core/src/api/authentication.ts b/superset-frontend/packages/superset-core/src/api/authentication.ts new file mode 100644 index 00000000000..9ece04cb765 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/authentication.ts @@ -0,0 +1,43 @@ +/** + * 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. + */ + +/** + * @fileoverview Authentication API for Superset extensions. + * + * This module provides functions for handling user authentication and security + * within Superset extensions. + */ + +/** + * Retrieves the CSRF token used for securing requests against cross-site request forgery attacks. + * This token should be included in the headers of POST, PUT, DELETE, and other state-changing + * HTTP requests to ensure they are authorized. + * + * @returns A promise that resolves to the CSRF token as a string, or undefined if not available. + * + * @example + * ```typescript + * const csrfToken = await getCSRFToken(); + * if (csrfToken) { + * // Include in request headers + * headers['X-CSRFToken'] = csrfToken; + * } + * ``` + */ +export declare function getCSRFToken(): Promise; diff --git a/superset-frontend/packages/superset-core/src/api/commands.ts b/superset-frontend/packages/superset-core/src/api/commands.ts new file mode 100644 index 00000000000..523f55238aa --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/commands.ts @@ -0,0 +1,70 @@ +/** + * 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. + */ + +/** + * @fileoverview Command system API for Superset extensions. + * + * This module provides a command registry and execution system that allows extensions + * to register custom commands and invoke them programmatically. Commands can be triggered + * via keyboard shortcuts, menu items, programmatic calls, or other user interactions. + */ + +import { Disposable } from './core'; + +/** + * Registers a command that can be invoked via a keyboard shortcut, + * a menu item, an action, or directly. + * + * Registering a command with an existing command identifier twice + * will cause an error. + * + * @param command A unique identifier for the command. + * @param callback A command handler function. + * @param thisArg The `this` context used when invoking the handler function. + * @returns Disposable which unregisters this command on disposal. + */ +export declare function registerCommand( + command: string, + callback: (...args: any[]) => any, + thisArg?: any, +): Disposable; + +/** + * Executes the command denoted by the given command identifier. + * + * @param command Identifier of the command to execute. + * @param rest Parameters passed to the command function. + * @returns A promise that resolves to the returned value of the given command. Returns `undefined` when + * the command handler function doesn't return anything. + */ +export declare function executeCommand( + command: string, + ...rest: any[] +): Promise; + +/** + * Retrieve the list of all available commands. Commands starting with an underscore are + * treated as internal commands. + * + * @param filterInternal Set `true` to not see internal commands (starting with an underscore) + * @returns Promise that resolves to a list of command ids. + */ +export declare function getCommands( + filterInternal?: boolean, +): Promise; diff --git a/superset-frontend/packages/superset-core/src/api/contributions.ts b/superset-frontend/packages/superset-core/src/api/contributions.ts new file mode 100644 index 00000000000..2cc3bbb6ae2 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/contributions.ts @@ -0,0 +1,90 @@ +/** + * 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. + */ + +/** + * @fileoverview Contributions API for Superset extension UI integration. + * + * This module defines the interfaces and types for extension contributions to the + * Superset user interface. Extensions use these contribution types to register + * commands, menu items, and custom views that integrate seamlessly with the + * Superset platform. The contribution system allows extensions to extend the + * application's functionality while maintaining a consistent user experience. + */ + +/** + * Describes a command that can be contributed to the application. + */ +export interface CommandContribution { + /** The unique identifier for the command. */ + command: string; + /** The icon associated with the command. */ + icon: string; + /** The display title of the command. */ + title: string; + /** A description of what the command does. */ + description: string; +} + +/** + * Represents a menu item that links a view to a command. + */ +export interface MenuItem { + /** The identifier of the view associated with this menu item. */ + view: string; + /** The command to execute when this menu item is selected. */ + command: string; +} + +/** + * Defines the structure of menu contributions, allowing for primary, secondary, and context menus. + */ +export interface MenuContribution { + /** Items to appear in the primary menu. */ + primary?: MenuItem[]; + /** Items to appear in the secondary menu. */ + secondary?: MenuItem[]; + /** Items to appear in the context menu. */ + context?: MenuItem[]; +} + +/** + * Represents a contributed view in the application. + */ +export interface ViewContribution { + /** The unique identifier for the view. */ + id: string; + /** The display name of the view. */ + name: string; +} + +/** + * Aggregates all contributions (commands, menus, and views) provided by an extension or module. + */ +export interface Contributions { + /** List of command contributions. */ + commands: CommandContribution[]; + /** Mapping of menu contributions by menu key. */ + menus: { + [key: string]: MenuContribution; + }; + /** Mapping of view contributions by view key. */ + views: { + [key: string]: ViewContribution[]; + }; +} diff --git a/superset-frontend/packages/superset-core/src/api/core.ts b/superset-frontend/packages/superset-core/src/api/core.ts new file mode 100644 index 00000000000..d46e198ecd7 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/core.ts @@ -0,0 +1,245 @@ +/** + * 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. + */ + +/** + * @fileoverview Core types and utilities for Superset extensions. + * + * This module provides fundamental types and interfaces used throughout the + * Superset extension API. It includes database metadata types, event handling, + * resource management, and extension lifecycle definitions. + */ + +import { ReactElement } from 'react'; +import { Contributions } from './contributions'; + +/** + * Represents a database column with its name and data type. + */ +export declare interface Column { + /** The name of the column */ + name: string; + /** The data type of the column (e.g., 'INTEGER', 'VARCHAR', 'TIMESTAMP') */ + type: string; +} + +/** + * Represents a database table with its name and column definitions. + */ +export declare interface Table { + /** The name of the table */ + name: string; + /** Array of columns in this table */ + columns: Column[]; +} + +/** + * Represents a database catalog. + * @todo This interface needs to be expanded with catalog-specific properties. + */ +export declare interface Catalog {} + +/** + * Represents a database schema containing tables. + */ +export declare interface Schema { + /** Array of tables in this schema */ + tables: Table[]; +} + +/** + * Represents a database connection with its metadata. + */ +export declare interface Database { + /** Unique identifier for the database */ + id: number; + /** Display name of the database */ + name: string; + /** Array of catalogs available in this database */ + catalogs: Catalog[]; + /** Array of schemas available in this database */ + schemas: Schema[]; +} + +/** + * Represents a type which can release resources, such + * as event listening or a timer. + */ +export declare class Disposable { + /** + * Combine many disposable-likes into one. You can use this method when having objects with + * a dispose function which aren't instances of `Disposable`. + * + * @param disposableLikes Objects that have at least a `dispose`-function member. Note that asynchronous + * dispose-functions aren't awaited. + * @returns Returns a new disposable which, upon dispose, will + * dispose all provided disposables. + */ + static from( + ...disposableLikes: { + /** + * Function to clean up resources. + */ + dispose: () => any; + }[] + ): Disposable; + + /** + * Creates a new disposable that calls the provided function + * on dispose. + * + * *Note* that an asynchronous function is not awaited. + * + * @param callOnDispose Function that disposes something. + */ + constructor(callOnDispose: () => any); + + /** + * Dispose this object. + */ + dispose(): any; +} + +/** + * Represents a typed event system for handling asynchronous notifications. + * + * A function that represents an event to which you subscribe by calling it with + * a listener function as argument. This provides a type-safe way to handle + * events throughout the Superset extension system. + * + * @template T The type of data that will be passed to event listeners. + * + * @example + * ```typescript + * // Subscribe to an event + * const disposable = myEvent((data) => { + * console.log("Event happened:", data); + * }); + * + * // Unsubscribe when done + * disposable.dispose(); + * ``` + */ +export declare interface Event { + /** + * Subscribe to this event by providing a listener function. + * + * @param listener The listener function that will be called when the event is fired. + * The function receives the event data as its parameter. + * @param thisArgs Optional `this` context that will be used when calling the event listener. + * @returns A Disposable object that can be used to unsubscribe from the event. + * + * @example + * ```typescript + * const subscription = onSomeEvent((data) => { + * console.log('Received:', data); + * }); + * + * // Later, clean up the subscription + * subscription.dispose(); + * ``` + */ + (listener: (e: T) => any, thisArgs?: any): Disposable; +} + +/** + * Represents a Superset extension with its metadata and lifecycle methods. + * Extensions are modular components that can extend Superset's functionality. + */ +export interface Extension { + /** Function called when the extension is activated */ + activate: Function; + /** UI contributions provided by this extension */ + contributions: Contributions; + /** Function called when the extension is deactivated */ + deactivate: Function; + /** List of other extensions that this extension depends on */ + dependencies: string[]; + /** Human-readable description of the extension */ + description: string; + /** List of modules exposed by this extension for use by other extensions */ + exposedModules: string[]; + /** List of other extensions that this extension depends on */ + extensionDependencies: string[]; + /** Unique identifier for the extension */ + id: string; + /** Human-readable name of the extension */ + name: string; + /** URL or path to the extension's remote entry point */ + remoteEntry: string; + /** Version of the extension */ + version: string; +} + +/** + * Context object provided to extensions during activation. + * Contains utilities and resources that extensions can use during their lifecycle. + */ +export interface ExtensionContext { + /** + * Array of disposable objects that will be automatically disposed when the extension is deactivated. + * Extensions should add any resources that need cleanup to this array. + * + * @example + * ```typescript + * export function activate(context: ExtensionContext) { + * // Register an event listener + * const disposable = onSomeEvent(() => { ... }); + * + * // Add to context so it's cleaned up automatically + * context.disposables.push(disposable); + * } + * ``` + */ + disposables: Disposable[]; + + /** + * @todo We might want to add more properties to this interface in the future like + * storage, configuration, logging, etc. For now, it serves as a placeholder + * to allow for future extensibility without breaking existing extensions. + */ +} + +/** + * Registers a view provider that can render custom React components in Superset. + * View providers allow extensions to contribute custom UI components that can be + * displayed in various parts of the Superset interface. + * + * @param id Unique identifier for the view provider. This ID is used to reference + * the view provider from other parts of the system. + * @param viewProvider Function that returns a React element to be rendered. + * This function will be called whenever the view needs to be displayed. + * @returns A Disposable object that can be used to unregister the view provider. + * + * @example + * ```typescript + * const disposable = registerViewProvider('my-extension.custom-view', () => ( + *
+ *

My Custom View

+ *

This is a custom component from my extension.

+ *
+ * )); + * + * // Later, unregister the view provider + * disposable.dispose(); + * ``` + */ +export declare const registerViewProvider: ( + id: string, + viewProvider: () => ReactElement, +) => Disposable; diff --git a/superset-frontend/packages/superset-core/src/api/environment.ts b/superset-frontend/packages/superset-core/src/api/environment.ts new file mode 100644 index 00000000000..285fae46b1b --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/environment.ts @@ -0,0 +1,153 @@ +/** + * 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. + */ + +/** + * @fileoverview Environment API for Superset extensions. + * + * This module provides access to the execution environment, including system + * clipboard operations, logging capabilities, internationalization features, + * and environment variables. It allows extensions to interact with the host + * system and platform in a controlled manner. + */ + +import { Event } from './core'; + +/** + * Interface for system clipboard operations. + * Provides methods to read from and write to the system clipboard. + */ +export interface Clipboard { + /** + * Read the current clipboard contents as text. + * + * @returns A promise that resolves to the clipboard text content. + * + * @example + * ```typescript + * const clipboardText = await clipboard.readText(); + * console.log('Clipboard contains:', clipboardText); + * ``` + */ + readText(): Promise; + + /** + * Writes text into the clipboard, replacing any existing content. + * + * @param value The text to write to the clipboard. + * @returns A promise that resolves when the write operation completes. + * + * @example + * ```typescript + * await clipboard.writeText('Hello, world!'); + * console.log('Text copied to clipboard'); + * ``` + */ + writeText(value: string): Promise; +} + +/** + * Logging levels for controlling the verbosity of log output. + * Higher numeric values indicate more restrictive logging levels. + */ +export enum LogLevel { + /** + * No messages are logged with this level. + * Use this to completely disable logging. + */ + Off = 0, + + /** + * All messages are logged with this level. + * Most verbose logging level, includes all types of messages. + */ + Trace = 1, + + /** + * Messages with debug and higher log level are logged with this level. + * Useful for development and troubleshooting. + */ + Debug = 2, + + /** + * Messages with info and higher log level are logged with this level. + * General informational messages about application flow. + */ + Info = 3, + + /** + * Messages with warning and higher log level are logged with this level. + * Indicates potential issues that don't prevent operation. + */ + Warning = 4, + + /** + * Only error messages are logged with this level. + * Most restrictive level, shows only critical failures. + */ + Error = 5, +} + +/** + * Represents the preferred user-language, like `de-CH`, `fr`, or `en-US`. + */ +export declare const language: string; + +/** + * The system clipboard. + */ +export declare const clipboard: Clipboard; + +/** + * The current log level of the editor. + */ +export declare const logLevel: LogLevel; + +/** + * An {@link Event} which fires when the log level of the editor changes. + */ +export declare const onDidChangeLogLevel: Event; + +/** + * Opens an external URL in the default system browser or application. + * This function provides a secure way to open external resources while + * respecting user security preferences. + * + * @param target The URL to open externally. + * @returns A promise that resolves to true if the URL was successfully opened, false otherwise. + * + * @example + * ```typescript + * const success = await openExternal(new URL('https://superset.apache.org')); + * if (success) { + * console.log('URL opened successfully'); + * } else { + * console.log('Failed to open URL'); + * } + * ``` + */ +export declare function openExternal(target: URL): Promise; + +/** + * Gets an environment variable value. + * @param name The name of the environment variable + * @returns The value of the environment variable or undefined if not found + */ +export declare function getEnvironmentVariable( + name: string, +): string | undefined; diff --git a/superset-frontend/packages/superset-core/src/api/extensions.ts b/superset-frontend/packages/superset-core/src/api/extensions.ts new file mode 100644 index 00000000000..5a4caea09a4 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/extensions.ts @@ -0,0 +1,69 @@ +/** + * 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. + */ + +/** + * @fileoverview Extensions API for Superset extension management. + * + * This module provides functions and events for managing Superset extensions, + * including querying extension metadata and monitoring extension lifecycle events. + * Extensions can use this API to discover other extensions and react to changes + * in the extension ecosystem. + */ + +import { Extension } from './core'; + +/** + * Get an extension by its full identifier in the form of: `publisher.name`. + * This function allows extensions to discover and interact with other extensions + * in the Superset ecosystem. + * + * @param extensionId An extension identifier in the format "publisher.name". + * @returns The extension object if found, or `undefined` if no extension matches the identifier. + * + * @example + * ```typescript + * const chartExtension = getExtension('superset.chart-plugins'); + * if (chartExtension) { + * console.log('Chart extension is available:', chartExtension.displayName); + * } else { + * console.log('Chart extension not found'); + * } + * ``` + */ +export declare function getExtension( + extensionId: string, +): Extension | undefined; + +/** + * Get all extensions currently known to the system. + * This function returns a readonly array containing all extensions that are installed + * and available, regardless of their activation status. + * + * @returns A readonly array of all extension objects in the system. + * + * @example + * ```typescript + * const extensions = getAllExtensions(); + * console.log(`Total extensions: ${extensions.length}`); + * extensions.forEach(ext => { + * console.log(`- ${ext.id}: ${ext.name} (enabled: ${ext.enabled})`); + * }); + * ``` + */ +export declare function getAllExtensions(): readonly Extension[]; diff --git a/superset-frontend/packages/superset-core/src/api/index.ts b/superset-frontend/packages/superset-core/src/api/index.ts new file mode 100644 index 00000000000..1c47fe884b7 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/index.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +/** + * @fileoverview Main entry point for the Superset Extension API. + * + * This module exports all public APIs for Superset extensions, providing + * a unified interface for extension developers to interact with the Superset + * platform. The API includes: + * + * - `authentication`: Handle user authentication and authorization + * - `commands`: Execute Superset commands and operations + * - `contributions`: Register UI contributions and customizations + * - `core`: Access fundamental Superset types and utilities + * - `environment`: Interact with the execution environment + * - `extensions`: Manage extension lifecycle and metadata + * - `sqlLab`: Integrate with SQL Lab functionality + */ + +export * as authentication from './authentication'; +export * as commands from './commands'; +export * as contributions from './contributions'; +export * as core from './core'; +export * as environment from './environment'; +export * as extensions from './extensions'; +export * as sqlLab from './sqlLab'; diff --git a/superset-frontend/packages/superset-core/src/api/sqlLab.ts b/superset-frontend/packages/superset-core/src/api/sqlLab.ts new file mode 100644 index 00000000000..997ec578090 --- /dev/null +++ b/superset-frontend/packages/superset-core/src/api/sqlLab.ts @@ -0,0 +1,420 @@ +/** + * 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. + */ + +/** + * @fileoverview SQL Lab API for Superset extensions. + * + * This module provides interfaces and functions for integrating with Superset's SQL Lab, + * allowing extensions to interact with SQL editors, tabs, panels, and query execution. + * Extensions can listen to various events and access current state information. + * + * The API is organized into two main categories: + * - Tab-scoped APIs: Functions and events available within the context of a specific tab + * - Global APIs: Functions and events available across the entire SQL Lab interface + */ + +import { Event, Database } from './core'; + +/** + * Represents an SQL editor instance within a SQL Lab tab. + * Contains the editor content and associated database connection information. + */ +export interface Editor { + /** + * The SQL content of the editor. + * This represents the current text in the SQL editor. + */ + content: string; + + /** + * The database identifier associated with the editor. + * This determines which database the queries will be executed against. + */ + databaseId: number; + + /** + * The catalog name associated with the editor. + * Can be null if no specific catalog is selected. + */ + catalog: string | null; + + /** + * The schema name associated with the editor. + * Defines the database schema context for the editor. + */ + schema: string; + + /** + * The table name associated with the editor. + * Can be null if no specific table is selected. + * + * @todo Revisit if we actually need the table property + */ + table: string | null; +} + +/** + * Represents a panel within a SQL Lab tab. + * Panels can display query results, database schema information, or other tools. + */ +export interface Panel { + /** + * The unique identifier of the panel. + * Used to distinguish between different panels in the same tab. + */ + id: string; +} + +/** + * Represents a tab in the SQL Lab interface. + * Each tab contains an SQL editor and can have multiple associated panels. + */ +export interface Tab { + /** + * The unique identifier of the tab. + * Used to identify and manage specific tabs. + */ + id: string; + + /** + * The display title of the tab. + * This is what users see in the tab header. + */ + title: string; + + /** + * The SQL editor instance associated with this tab. + * Contains the editor content and database connection settings. + */ + editor: Editor; + + /** + * The panels associated with the tab. + * Panels provide additional functionality like result display and schema browsing. + */ + panels: Panel[]; +} + +/** + * Tab-scoped Events and Functions + * + * These APIs are available within the context of a specific SQL Lab tab and provide + * access to tab-specific state and events. + */ + +/** + * Gets the currently active tab in SQL Lab. + * + * @returns The current tab object, or undefined if no tab is active. + * + * @example + * ```typescript + * const tab = getCurrentTab(); + * if (tab) { + * console.log(`Active tab: ${tab.title}`); + * console.log(`Database ID: ${tab.editor.databaseId}`); + * } + * ``` + */ +export declare const getCurrentTab: () => Tab | undefined; + +/** + * Event fired when the content of the SQL editor changes. + * Provides the new content as the event payload. + * + * @example + * ```typescript + * onDidChangeEditorContent.event((newContent) => { + * console.log('Editor content changed:', newContent.length, 'characters'); + * }); + * ``` + */ +export declare const onDidChangeEditorContent: Event; + +/** + * Event fired when the database selection changes in the editor. + * Provides the new database ID as the event payload. + * + * @example + * ```typescript + * onDidChangeEditorDatabase.event((databaseId) => { + * console.log('Database changed to:', databaseId); + * }); + * ``` + */ +export declare const onDidChangeEditorDatabase: Event; + +/** + * Event fired when the catalog selection changes in the editor. + * Provides the new catalog name as the event payload. + * + * @example + * ```typescript + * onDidChangeEditorCatalog.event((catalog) => { + * console.log('Catalog changed to:', catalog); + * }); + * ``` + */ +export declare const onDidChangeEditorCatalog: Event; + +/** + * Event fired when the schema selection changes in the editor. + * Provides the new schema name as the event payload. + * + * @example + * ```typescript + * onDidChangeEditorSchema.event((schema) => { + * console.log('Schema changed to:', schema); + * }); + * ``` + */ +export declare const onDidChangeEditorSchema: Event; + +/** + * Event fired when the table selection changes in the editor. + * Provides the new table name as the event payload. + * + * @example + * ```typescript + * onDidChangeEditorTable.event((table) => { + * console.log('Table changed to:', table); + * }); + * ``` + */ +export declare const onDidChangeEditorTable: Event; + +/** + * Event fired when a panel is closed in the current tab. + * Provides the closed panel object as the event payload. + * + * @example + * ```typescript + * onDidClosePanel.event((panel) => { + * console.log('Panel closed:', panel.id); + * }); + * ``` + */ +export declare const onDidClosePanel: Event; + +/** + * Event fired when the active panel changes in the current tab. + * Provides the newly active panel object as the event payload. + * + * @example + * ```typescript + * onDidChangeActivePanel.event((panel) => { + * console.log('Active panel changed to:', panel.id); + * }); + * ``` + */ +export declare const onDidChangeActivePanel: Event; + +/** + * Event fired when the title of the current tab changes. + * Provides the new title as the event payload. + * + * @example + * ```typescript + * onDidChangeTabTitle.event((title) => { + * console.log('Tab title changed to:', title); + * }); + * ``` + */ +export declare const onDidChangeTabTitle: Event; + +/** + * Event fired when a query starts running in the current tab. + * Provides the editor state at the time of query execution. + * + * @example + * ```typescript + * onDidQueryRun.event((editor) => { + * console.log('Query started on database:', editor.databaseId); + * console.log('Query content:', editor.content); + * }); + * ``` + */ +export declare const onDidQueryRun: Event; + +/** + * Event fired when a running query is stopped in the current tab. + * Provides the editor state when the query was stopped. + * + * @example + * ```typescript + * onDidQueryStop.event((editor) => { + * console.log('Query stopped for database:', editor.databaseId); + * }); + * ``` + */ +export declare const onDidQueryStop: Event; + +/** + * Event fired when a query fails in the current tab. + * + * @todo Check what's the state object for onDidQueryFail and onDidQuerySuccess. + * Currently it's a string, but it should be an object with properties like queryId, status, etc. + * + * @example + * ```typescript + * onDidQueryFail.event((error) => { + * console.error('Query failed:', error); + * }); + * ``` + */ +export declare const onDidQueryFail: Event; + +/** + * Event fired when a query succeeds in the current tab. + * + * @todo Check what's the state object for onDidQueryFail and onDidQuerySuccess. + * Currently it's a string, but it should be an object with properties like queryId, status, etc. + * + * @example + * ```typescript + * onDidQuerySuccess.event((result) => { + * console.log('Query succeeded:', result); + * }); + * ``` + */ +export declare const onDidQuerySuccess: Event; + +/** + * Global Events and Functions + * + * These APIs are available across the entire SQL Lab interface and provide + * access to global state and events that affect the overall SQL Lab experience. + */ + +/** + * Gets all available databases in the Superset instance. + * + * @returns An array of database objects that the current user has access to. + * + * @example + * ```typescript + * const databases = getDatabases(); + * console.log(`Available databases: ${databases.length}`); + * databases.forEach(db => { + * console.log(`- ${db.database_name} (ID: ${db.id})`); + * }); + * ``` + */ +export declare const getDatabases: () => Database[]; + +/** + * Gets all tabs currently open in SQL Lab. + * + * @returns An array of all open tab objects. + * + * @example + * ```typescript + * const tabs = getTabs(); + * console.log(`Open tabs: ${tabs.length}`); + * tabs.forEach(tab => { + * console.log(`- ${tab.title} (ID: ${tab.id})`); + * }); + * ``` + */ +export declare const getTabs: () => Tab[]; + +/** + * Event fired when a tab is closed in SQL Lab. + * Provides the closed tab object as the event payload. + * + * @example + * ```typescript + * onDidCloseTab.event((tab) => { + * console.log('Tab closed:', tab.title); + * // Clean up any tab-specific resources + * }); + * ``` + */ +export declare const onDidCloseTab: Event; + +/** + * Event fired when the active tab changes in SQL Lab. + * Provides the newly active tab object as the event payload. + * + * @example + * ```typescript + * onDidChangeActiveTab.event((tab) => { + * console.log('Active tab changed to:', tab.title); + * // Update UI based on new active tab + * }); + * ``` + */ +export declare const onDidChangeActiveTab: Event; + +/** + * Event fired when the databases list is refreshed. + * This can happen when new databases are added or existing ones are modified. + * + * @example + * ```typescript + * onDidRefreshDatabases.event(() => { + * console.log('Databases refreshed, updating UI...'); + * const updatedDatabases = getDatabases(); + * // Update UI with new database list + * }); + * ``` + */ +export declare const onDidRefreshDatabases: Event; + +/** + * Event fired when the catalogs list is refreshed for the current database. + * This typically happens when switching databases or when catalog metadata is updated. + * + * @example + * ```typescript + * onDidRefreshCatalogs.event(() => { + * console.log('Catalogs refreshed'); + * // Update catalog dropdown or related UI + * }); + * ``` + */ +export declare const onDidRefreshCatalogs: Event; + +/** + * Event fired when the schemas list is refreshed for the current database/catalog. + * This happens when switching databases/catalogs or when schema metadata is updated. + * + * @example + * ```typescript + * onDidRefreshSchemas.event(() => { + * console.log('Schemas refreshed'); + * // Update schema dropdown or related UI + * }); + * ``` + */ +export declare const onDidRefreshSchemas: Event; + +/** + * Event fired when the tables list is refreshed for the current database/catalog/schema. + * This happens when switching schema contexts or when table metadata is updated. + * + * @example + * ```typescript + * onDidRefreshTables.event(() => { + * console.log('Tables refreshed'); + * // Update table browser or autocomplete suggestions + * }); + * ``` + */ +export declare const onDidRefreshTables: Event; diff --git a/superset-frontend/packages/superset-core/src/index.ts b/superset-frontend/packages/superset-core/src/index.ts new file mode 100644 index 00000000000..74c1aea3d6a --- /dev/null +++ b/superset-frontend/packages/superset-core/src/index.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ +export * from './api'; diff --git a/superset-frontend/packages/superset-core/tsconfig.json b/superset-frontend/packages/superset-core/tsconfig.json new file mode 100644 index 00000000000..3ce1be03ce0 --- /dev/null +++ b/superset-frontend/packages/superset-core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationDir": "lib", + "outDir": "lib", + "strict": true, + "rootDir": "src", + "jsx": "preserve", + "baseUrl": ".", + "module": "esnext", + "moduleResolution": "node", + "skipLibCheck": true + }, + "include": ["src/**/*.ts*"], + "exclude": ["lib"] +} diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/index.ts b/superset-frontend/packages/superset-ui-chart-controls/src/index.ts index 2a598267fb8..4ae84e2c969 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/index.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/index.ts @@ -16,14 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import * as sectionsModule from './sections'; export * from './utils'; export * from './constants'; export * from './operators'; -// can't do `export * as sections from './sections'`, babel-transformer will fail -export const sections = sectionsModule; +export * as sections from './sections'; export * from './components/ColumnOption'; export * from './components/ColumnTypeLabel/ColumnTypeLabel'; diff --git a/superset-frontend/packages/superset-ui-core/src/components/Popconfirm/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Popconfirm/index.tsx new file mode 100644 index 00000000000..8cee330b154 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/Popconfirm/index.tsx @@ -0,0 +1,26 @@ +/** + * 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 { Popconfirm as AntdPopconfirm } from 'antd'; +import { PopconfirmProps as AntdPopconfirmProps } from 'antd/es/popconfirm'; + +export interface PopconfirmProps extends AntdPopconfirmProps {} + +export const Popconfirm = (props: PopconfirmProps) => ( + +); diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 1aea7e70bd5..8cb4a3af9a8 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -159,6 +159,7 @@ export { } from './Typography'; export { Image, type ImageProps } from './Image'; +export { Popconfirm, type PopconfirmProps } from './Popconfirm'; export { Upload, type UploadFile, type UploadChangeParam } from './Upload'; // Add these to your index.ts export * from './Menu'; diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts index f102cd197da..7802c5a064d 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClient.ts @@ -50,6 +50,7 @@ const SupersetClient: SupersetClientInterface = { put: request => getInstance().put(request), reAuthenticate: () => getInstance().reAuthenticate(), request: request => getInstance().request(request), + getCSRFToken: () => getInstance().getCSRFToken(), }; export default SupersetClient; diff --git a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts index 4b826fd27bf..b5ceb932c21 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/SupersetClientClass.ts @@ -112,7 +112,7 @@ export default class SupersetClientClass { if (this.isAuthenticated() && !force) { return this.csrfPromise as CsrfPromise; } - return this.getCSRFToken(); + return this.fetchCSRFToken(); } async postForm( @@ -227,7 +227,7 @@ export default class SupersetClientClass { ); } - async getCSRFToken() { + async fetchCSRFToken() { this.csrfToken = undefined; // If we can request this resource successfully, it means that the user has // authenticated. If not we throw an error prompting to authenticate. @@ -257,6 +257,10 @@ export default class SupersetClientClass { return this.csrfPromise; } + async getCSRFToken() { + return this.csrfToken || this.fetchCSRFToken(); + } + getUrl({ host: inputHost, endpoint = '', diff --git a/superset-frontend/packages/superset-ui-core/src/connection/types.ts b/superset-frontend/packages/superset-ui-core/src/connection/types.ts index e8e6c97771d..3c05e2049dd 100644 --- a/superset-frontend/packages/superset-ui-core/src/connection/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/connection/types.ts @@ -162,6 +162,7 @@ export interface SupersetClientInterface > { configure: (config?: ClientConfig) => SupersetClientInterface; reset: () => void; + getCSRFToken: () => CsrfPromise; } export type SupersetClientResponse = Response | JsonResponse | TextResponse; diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 2ac05404242..00d323bdae8 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -41,6 +41,7 @@ export enum FeatureFlag { EmbeddableCharts = 'EMBEDDABLE_CHARTS', EmbeddedSuperset = 'EMBEDDED_SUPERSET', EnableAdvancedDataTypes = 'ENABLE_ADVANCED_DATA_TYPES', + EnableExtensions = 'ENABLE_EXTENSIONS', /** @deprecated */ EnableJavascriptControls = 'ENABLE_JAVASCRIPT_CONTROLS', EnableTemplateProcessing = 'ENABLE_TEMPLATE_PROCESSING', diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts index 0e545d50fc1..7f29db6123b 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts @@ -79,7 +79,7 @@ describe('SupersetClient', () => { SupersetClientClass.prototype, 'isAuthenticated', ); - const csrfSpy = jest.spyOn(SupersetClientClass.prototype, 'getCSRFToken'); + const csrfSpy = jest.spyOn(SupersetClientClass.prototype, 'fetchCSRFToken'); const requestSpy = jest.spyOn(SupersetClientClass.prototype, 'request'); const getGuestTokenSpy = jest.spyOn( SupersetClientClass.prototype, diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx index 2734d881546..ea14a8e17b6 100644 --- a/superset-frontend/spec/helpers/testing-library.tsx +++ b/superset-frontend/spec/helpers/testing-library.tsx @@ -43,6 +43,7 @@ import { QueryParamProvider } from 'use-query-params'; import { configureStore, Store } from '@reduxjs/toolkit'; import { api } from 'src/hooks/apiResources/queryApi'; import userEvent from '@testing-library/user-event'; +import { ExtensionsProvider } from 'src/extensions/ExtensionsContext'; type Options = Omit & { useRedux?: boolean; @@ -85,7 +86,9 @@ export function createWrapper(options?: Options) { return ({ children }: { children?: ReactNode }) => { let result = ( - {children} + + {children} + ); if (useTheme) { diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 613c0f4085e..27567be6ac6 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -27,6 +27,8 @@ import { removeTables, setActiveSouthPaneTab } from 'src/SqlLab/actions/sqlLab'; import { Label } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { SqlLabRootState } from 'src/SqlLab/types'; +import { useExtensionsContext } from 'src/extensions/ExtensionsContext'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import QueryHistory from '../QueryHistory'; import { @@ -93,6 +95,9 @@ const SouthPane = ({ const editorId = tabViewId ?? id; const theme = useTheme(); const dispatch = useDispatch(); + const contributions = + ExtensionsManager.getInstance().getViewContributions('sqllab.panels') || []; + const { getView } = useExtensionsContext(); const { offline, tables } = useSelector( ({ sqlLab: { offline, tables } }: SqlLabRootState) => ({ offline, @@ -192,6 +197,13 @@ const SouthPane = ({ /> ), })), + ...contributions.map(contribution => ({ + key: contribution.id, + label: contribution.name, + children: getView(contribution.id), + forceRender: true, + closable: false, + })), ]; return ( diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index e20282f84e2..168c24c5796 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -113,6 +113,8 @@ import { LOG_ACTIONS_SQLLAB_STOP_QUERY, Logger, } from 'src/logger/LogUtils'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; +import { commands } from 'src/core'; import { CopyToClipboard } from 'src/components'; import TemplateParamsEditor from '../TemplateParamsEditor'; import SouthPane from '../SouthPane'; @@ -635,6 +637,23 @@ const SqlEditor: FC = ({ ? t('Schedule the query periodically') : t('You must run the query successfully first'); + const contributions = + ExtensionsManager.getInstance().getMenuContributions('sqllab.editor'); + + const secondaryContributions = (contributions?.secondary || []).map( + contribution => { + const command = ExtensionsManager.getInstance().getCommandContribution( + contribution.command, + )!; + return { + key: command.command, + label: command.title, + title: command.description, + onClick: () => commands.executeCommand(command.command), + }; + }, + ); + const menuItems: MenuItemType[] = [ { key: 'render-html', @@ -706,6 +725,7 @@ const SqlEditor: FC = ({ ), }, + ...secondaryContributions, ].filter(Boolean) as MenuItemType[]; return ; @@ -719,6 +739,30 @@ const SqlEditor: FC = ({ const renderEditorBottomBar = (hideActions: boolean) => { const { allow_ctas: allowCTAS, allow_cvas: allowCVAS } = database || {}; + const contributions = + ExtensionsManager.getInstance().getMenuContributions('sqllab.editor'); + + const primaryContributions = (contributions?.primary || []).map( + contribution => { + const command = ExtensionsManager.getInstance().getCommandContribution( + contribution.command, + )!; + // @ts-ignore + const Icon = Icons[command?.icon as IconNameType]; + + return ( + + ); + }, + ); + const showMenu = allowCTAS || allowCVAS; const menuItems: MenuItemType[] = [ allowCTAS && { @@ -815,6 +859,7 @@ const SqlEditor: FC = ({ +
{primaryContributions}
renderDropdown()} trigger={['click']} diff --git a/superset-frontend/src/core/authentication.ts b/superset-frontend/src/core/authentication.ts new file mode 100644 index 00000000000..5b79f197739 --- /dev/null +++ b/superset-frontend/src/core/authentication.ts @@ -0,0 +1,27 @@ +/** + * 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 type { authentication as authenticationType } from '@apache-superset/core'; +import { SupersetClient } from '@superset-ui/core'; + +const getCSRFToken: typeof authenticationType.getCSRFToken = () => + SupersetClient.getCSRFToken(); + +export const authentication: typeof authenticationType = { + getCSRFToken, +}; diff --git a/superset-frontend/src/core/commands.ts b/superset-frontend/src/core/commands.ts new file mode 100644 index 00000000000..c507754839a --- /dev/null +++ b/superset-frontend/src/core/commands.ts @@ -0,0 +1,64 @@ +/** + * 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 { logging } from '@superset-ui/core'; +import type { commands as commandsType } from '@apache-superset/core'; +import { Disposable } from './core'; + +const commandRegistry: Map any> = new Map(); + +const registerCommand: typeof commandsType.registerCommand = ( + command, + callback, + thisArg, +) => { + if (commandRegistry.has(command)) { + logging.warn( + `Command "${command}" is already registered. Overwriting the existing command.`, + ); + } + const boundCallback = thisArg ? callback.bind(thisArg) : callback; + commandRegistry.set(command, boundCallback); + return new Disposable(() => { + commandRegistry.delete(command); + }); +}; + +const executeCommand: typeof commandsType.executeCommand = async ( + command: string, + ...args: any[] +): Promise => { + const callback = commandRegistry.get(command); + if (!callback) { + throw new Error(`Command "${command}" not found.`); + } + return callback(...args) as T; +}; + +const getCommands: typeof commandsType.getCommands = filterInternal => { + const commands = Array.from(commandRegistry.keys()); + return Promise.resolve( + filterInternal ? commands.filter(cmd => !cmd.startsWith('_')) : commands, + ); +}; + +export const commands: typeof commandsType = { + registerCommand, + executeCommand, + getCommands, +}; diff --git a/superset-frontend/src/core/core.ts b/superset-frontend/src/core/core.ts new file mode 100644 index 00000000000..34ba4802486 --- /dev/null +++ b/superset-frontend/src/core/core.ts @@ -0,0 +1,195 @@ +/** + * 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 type { + core as coreType, + sqlLab as sqlLabType, +} from '@apache-superset/core'; +import { getExtensionsContextValue } from '../extensions/ExtensionsContextUtils'; + +export class Column implements coreType.Column { + name: string; + + type: string; + + constructor(name: string, type: string) { + this.name = name; + this.type = type; + } +} + +export class Table implements coreType.Table { + name: string; + + columns: Column[]; + + constructor(name: string, columns: Column[]) { + this.name = name; + this.columns = columns; + } + + addColumn(column: Column): void { + this.columns.push(column); + } +} + +export class Catalog implements coreType.Catalog { + name: string; + + description?: string; + + constructor(name: string, description?: string) { + this.name = name; + this.description = description; + } +} + +export class Schema implements coreType.Schema { + tables: Table[]; + + constructor(tables: Table[]) { + this.tables = tables; + } + + addTable(table: Table): void { + this.tables.push(table); + } +} + +export class Database implements coreType.Database { + id: number; + + name: string; + + catalogs: Catalog[]; + + schemas: Schema[]; + + constructor( + id: number, + name: string, + catalogs: Catalog[], + schemas: Schema[], + ) { + this.id = id; + this.name = name; + this.catalogs = catalogs; + this.schemas = schemas; + } + + addCatalog(catalog: Catalog): void { + this.catalogs.push(catalog); + } + + addSchema(schema: Schema): void { + this.schemas.push(schema); + } +} + +export class Disposable implements coreType.Disposable { + static from( + ...disposableLikes: { + dispose: () => any; + }[] + ): Disposable { + return new Disposable(() => { + disposableLikes.forEach(disposable => { + disposable.dispose(); + }); + }); + } + + constructor(callOnDispose: () => any) { + this.dispose = callOnDispose; + } + + dispose(): any { + this.dispose(); + } +} + +export class ExtensionContext implements coreType.ExtensionContext { + disposables: coreType.Disposable[] = []; +} + +export class Panel implements sqlLabType.Panel { + id: string; + + constructor(id: string) { + this.id = id; + } +} + +export class Editor implements sqlLabType.Editor { + content: string; + + databaseId: number; + + schema: string; + + // TODO: Check later if we'll use objects instead of strings. + catalog: string | null; + + table: string | null; + + constructor( + content: string, + databaseId: number, + catalog: string | null = null, + schema = '', + table: string | null = null, + ) { + this.content = content; + this.databaseId = databaseId; + this.catalog = catalog; + this.schema = schema; + this.table = table; + } +} + +export class Tab implements sqlLabType.Tab { + id: string; + + title: string; + + editor: Editor; + + panels: Panel[]; + + constructor(id: string, title: string, editor: Editor, panels: Panel[] = []) { + this.id = id; + this.title = title; + this.editor = editor; + this.panels = panels; + } +} + +const registerViewProvider: typeof coreType.registerViewProvider = ( + id, + viewProvider, +) => { + const { registerViewProvider: register, unregisterViewProvider: unregister } = + getExtensionsContextValue(); + register(id, viewProvider); + return new Disposable(() => unregister(id)); +}; + +export const core: typeof coreType = { + registerViewProvider, + Disposable, +}; diff --git a/superset-frontend/src/core/environment.ts b/superset-frontend/src/core/environment.ts new file mode 100644 index 00000000000..54aa758cff4 --- /dev/null +++ b/superset-frontend/src/core/environment.ts @@ -0,0 +1,57 @@ +/** + * 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 { environment as environmentType } from '@apache-superset/core'; + +const { LogLevel } = environmentType; + +const clipboard: typeof environmentType.clipboard = { + readText: async () => { + throw new Error('Not implemented yet'); + }, + writeText: async () => { + throw new Error('Not implemented yet'); + }, +}; + +const language: typeof environmentType.language = navigator.language || 'en-US'; + +const logLevel: typeof environmentType.logLevel = LogLevel.Info; + +const onDidChangeLogLevel: typeof environmentType.onDidChangeLogLevel = () => { + throw new Error('Not implemented yet'); +}; + +const openExternal: typeof environmentType.openExternal = async () => { + throw new Error('Not implemented yet'); +}; + +const getEnvironmentVariable: typeof environmentType.getEnvironmentVariable = + () => { + throw new Error('Not implemented yet'); + }; + +export const environment: typeof environmentType = { + clipboard, + language, + logLevel, + onDidChangeLogLevel, + openExternal, + getEnvironmentVariable, + LogLevel, +}; diff --git a/superset-frontend/src/core/extensions.ts b/superset-frontend/src/core/extensions.ts new file mode 100644 index 00000000000..1b213bf7931 --- /dev/null +++ b/superset-frontend/src/core/extensions.ts @@ -0,0 +1,32 @@ +/** + * 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 type { extensions as extensionsType } from '@apache-superset/core'; +import ExtensionsManager from 'src/extensions/ExtensionsManager'; + +const getExtension: typeof extensionsType.getExtension = id => { + throw new Error('Not implemented yet'); +}; + +const getAllExtensions: typeof extensionsType.getAllExtensions = () => + ExtensionsManager.getInstance().getExtensions(); + +export const extensions: typeof extensionsType = { + getExtension, + getAllExtensions, +}; diff --git a/superset-frontend/src/core/index.ts b/superset-frontend/src/core/index.ts new file mode 100644 index 00000000000..e5cd32b2f94 --- /dev/null +++ b/superset-frontend/src/core/index.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ +export * from './authentication'; +export * from './core'; +export * from './commands'; +export * from './extensions'; +export * from './environment'; +export * from './sqlLab'; diff --git a/superset-frontend/src/core/sqlLab.ts b/superset-frontend/src/core/sqlLab.ts new file mode 100644 index 00000000000..8f374dea1be --- /dev/null +++ b/superset-frontend/src/core/sqlLab.ts @@ -0,0 +1,203 @@ +/** + * 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 type { sqlLab as sqlLabType } from '@apache-superset/core'; +import { + QUERY_FAILED, + QUERY_SUCCESS, + QUERY_EDITOR_SETDB, + querySuccess, +} from 'src/SqlLab/actions/sqlLab'; +import { RootState, store } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; +import type { SqlLabRootState } from 'src/SqlLab/types'; +import { Disposable, Editor, Panel, Tab } from './core'; +import { createActionListener } from './utils'; + +const activeEditorId = () => { + const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState(); + const { tabHistory } = sqlLab; + return tabHistory[tabHistory.length - 1]; +}; + +const getCurrentTab: typeof sqlLabType.getCurrentTab = () => { + const { sqlLab }: { sqlLab: SqlLabRootState['sqlLab'] } = store.getState(); + const { queryEditors } = sqlLab; + const queryEditor = queryEditors.find( + editor => editor.id === activeEditorId(), + ); + if (queryEditor) { + const { id, name } = queryEditor; + const editor = new Editor( + queryEditor.sql, + queryEditor.dbId!, + queryEditor.catalog, + queryEditor.schema, + null, // TODO: Populate table if needed + ); + const panels: Panel[] = []; // TODO: Populate panels + + return new Tab(id, name, editor, panels); + } + return undefined; +}; + +const predicate = (actionType: string): AnyListenerPredicate => { + // Uses closure to capture the active editor ID at the time the listener is created + const id = activeEditorId(); + return action => + // Compares the original id with the current active editor ID + action.type === actionType && activeEditorId() === id; +}; + +export const onDidQueryRun: typeof sqlLabType.onDidQueryRun = ( + listener: (editor: sqlLabType.Editor) => void, + thisArgs?: any, +): Disposable => + createActionListener( + predicate(QUERY_SUCCESS), + listener, + (action: ReturnType) => { + const { query } = action; + const { dbId, catalog, schema, sql } = query; + return new Editor(sql, dbId, catalog, schema); + }, + thisArgs, + ); + +export const onDidQueryFail: typeof sqlLabType.onDidQueryFail = ( + listener: (e: string) => void, + thisArgs?: any, +): Disposable => + createActionListener( + predicate(QUERY_FAILED), + listener, + (action: { + type: string; + query: any; + msg: string; + link: any; + errors: any; + }) => action.msg, + thisArgs, + ); + +export const onDidChangeEditorDatabase: typeof sqlLabType.onDidChangeEditorDatabase = + (listener: (e: number) => void, thisArgs?: any): Disposable => + createActionListener( + predicate(QUERY_EDITOR_SETDB), + listener, + (action: { type: string; queryEditor: { dbId: number } }) => + action.queryEditor.dbId, + thisArgs, + ); + +const onDidChangeEditorContent: typeof sqlLabType.onDidChangeEditorContent = + () => { + throw new Error('Not implemented yet'); + }; + +const onDidChangeEditorCatalog: typeof sqlLabType.onDidChangeEditorCatalog = + () => { + throw new Error('Not implemented yet'); + }; + +const onDidChangeEditorSchema: typeof sqlLabType.onDidChangeEditorSchema = + () => { + throw new Error('Not implemented yet'); + }; + +const onDidChangeEditorTable: typeof sqlLabType.onDidChangeEditorTable = () => { + throw new Error('Not implemented yet'); +}; + +const onDidClosePanel: typeof sqlLabType.onDidClosePanel = () => { + throw new Error('Not implemented yet'); +}; + +const onDidChangeActivePanel: typeof sqlLabType.onDidChangeActivePanel = () => { + throw new Error('Not implemented yet'); +}; + +const onDidChangeTabTitle: typeof sqlLabType.onDidChangeTabTitle = () => { + throw new Error('Not implemented yet'); +}; + +const onDidQueryStop: typeof sqlLabType.onDidQueryStop = () => { + throw new Error('Not implemented yet'); +}; + +const onDidQuerySuccess: typeof sqlLabType.onDidQuerySuccess = () => { + throw new Error('Not implemented yet'); +}; + +const getDatabases: typeof sqlLabType.getDatabases = () => { + throw new Error('Not implemented yet'); +}; + +const getTabs: typeof sqlLabType.getTabs = () => { + throw new Error('Not implemented yet'); +}; + +const onDidCloseTab: typeof sqlLabType.onDidCloseTab = () => { + throw new Error('Not implemented yet'); +}; + +const onDidChangeActiveTab: typeof sqlLabType.onDidChangeActiveTab = () => { + throw new Error('Not implemented yet'); +}; + +const onDidRefreshDatabases: typeof sqlLabType.onDidRefreshDatabases = () => { + throw new Error('Not implemented yet'); +}; + +const onDidRefreshCatalogs: typeof sqlLabType.onDidRefreshCatalogs = () => { + throw new Error('Not implemented yet'); +}; + +const onDidRefreshSchemas: typeof sqlLabType.onDidRefreshSchemas = () => { + throw new Error('Not implemented yet'); +}; + +const onDidRefreshTables: typeof sqlLabType.onDidRefreshTables = () => { + throw new Error('Not implemented yet'); +}; + +export const sqlLab: typeof sqlLabType = { + getCurrentTab, + onDidChangeEditorContent, + onDidChangeEditorDatabase, + onDidChangeEditorCatalog, + onDidChangeEditorSchema, + onDidChangeEditorTable, + onDidClosePanel, + onDidChangeActivePanel, + onDidChangeTabTitle, + onDidQueryRun, + onDidQueryStop, + onDidQueryFail, + onDidQuerySuccess, + getDatabases, + getTabs, + onDidCloseTab, + onDidChangeActiveTab, + onDidRefreshDatabases, + onDidRefreshCatalogs, + onDidRefreshSchemas, + onDidRefreshTables, +}; diff --git a/superset-frontend/src/core/utils.ts b/superset-frontend/src/core/utils.ts new file mode 100644 index 00000000000..c095764743e --- /dev/null +++ b/superset-frontend/src/core/utils.ts @@ -0,0 +1,45 @@ +/** + * 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 type { core } from '@apache-superset/core'; +import { AnyAction } from 'redux'; +import { listenerMiddleware, RootState, store } from 'src/views/store'; +import { AnyListenerPredicate } from '@reduxjs/toolkit'; + +export function createActionListener( + predicate: AnyListenerPredicate, + listener: (v: V) => void, + valueParser: (action: AnyAction, state: RootState) => V, + thisArgs?: any, +): core.Disposable { + const boundListener = thisArgs ? listener.bind(thisArgs) : listener; + + const unsubscribe = listenerMiddleware.startListening({ + predicate, + effect: (action: AnyAction) => { + const state = store.getState(); + boundListener(valueParser(action, state)); + }, + }); + + return { + dispose: () => { + unsubscribe(); + }, + }; +} diff --git a/superset-frontend/src/extensions/ExtensionPlaceholder.test.tsx b/superset-frontend/src/extensions/ExtensionPlaceholder.test.tsx new file mode 100644 index 00000000000..6d2e3a32271 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionPlaceholder.test.tsx @@ -0,0 +1,43 @@ +/** + * 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 { render, screen } from 'spec/helpers/testing-library'; +import ExtensionPlaceholder from './ExtensionPlaceholder'; + +test('renders the placeholder component with correct text', () => { + render(, { useTheme: true }); + + expect( + screen.getByText('The extension test-extension could not be loaded.'), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'This may be due to the extension not being activated or the content not being available.', + ), + ).toBeInTheDocument(); +}); + +test('renders with the empty state image', () => { + render(, { useTheme: true }); + + // Check that the EmptyState component is rendered with the correct props + const emptyStateContainer = screen + .getByText('The extension test-extension could not be loaded.') + .closest('div'); + expect(emptyStateContainer).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/extensions/ExtensionPlaceholder.tsx b/superset-frontend/src/extensions/ExtensionPlaceholder.tsx new file mode 100644 index 00000000000..b9b721e084b --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionPlaceholder.tsx @@ -0,0 +1,32 @@ +/** + * 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 { EmptyState } from '@superset-ui/core/components'; +import { t } from '@superset-ui/core'; + +const ExtensionPlaceholder = ({ id }: { id: string }) => ( + +); + +export default ExtensionPlaceholder; diff --git a/superset-frontend/src/extensions/ExtensionsContext.test.tsx b/superset-frontend/src/extensions/ExtensionsContext.test.tsx new file mode 100644 index 00000000000..da9047d0c66 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsContext.test.tsx @@ -0,0 +1,150 @@ +/** + * 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 { ReactElement } from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { render } from 'spec/helpers/testing-library'; +import { ExtensionsProvider, useExtensionsContext } from './ExtensionsContext'; + +test('provides extensions context with initial empty state', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useExtensionsContext(), { wrapper }); + + const view = result.current.getView('non-existent'); + expect(view).not.toBeNull(); + // Should return a placeholder when no provider is registered + const { getByText } = render(view); + expect( + getByText('The extension non-existent could not be loaded.'), + ).toBeInTheDocument(); + expect( + getByText(/This may be due to the extension not being activated/), + ).toBeInTheDocument(); + + expect(typeof result.current.getView).toBe('function'); + expect(typeof result.current.registerViewProvider).toBe('function'); + expect(typeof result.current.unregisterViewProvider).toBe('function'); +}); + +test('registers a view provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useExtensionsContext(), { wrapper }); + + const mockViewProvider = (): ReactElement =>
Mock View
; + + result.current.registerViewProvider('test-view', mockViewProvider); + + const view = result.current.getView('test-view'); + expect(view).not.toBeNull(); +}); + +test('unregisters a view provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useExtensionsContext(), { wrapper }); + + const mockViewProvider = (): ReactElement => ( +
Mock View
+ ); + + // First register a view provider + result.current.registerViewProvider('test-view', mockViewProvider); + const registeredView = result.current.getView('test-view'); + const { getByTestId: getByTestIdRegistered } = render(registeredView); + expect(getByTestIdRegistered('registered-view')).toBeInTheDocument(); + + // Then unregister it - should return placeholder instead + result.current.unregisterViewProvider('test-view'); + const unregisteredView = result.current.getView('test-view'); + const { getByText } = render(unregisteredView); + expect( + getByText('The extension test-view could not be loaded.'), + ).toBeInTheDocument(); + expect( + getByText(/This may be due to the extension not being activated/), + ).toBeInTheDocument(); +}); + +test('throws error when useExtensionsContext is used outside provider', () => { + const { result } = renderHook(() => useExtensionsContext()); + + expect(result.error).toEqual( + Error('useExtensionsContext must be used within a ExtensionsProvider'), + ); +}); + +test('getView returns the correct rendered component', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useExtensionsContext(), { wrapper }); + + const MockComponent = (): ReactElement => ( +
Mock View Content
+ ); + + result.current.registerViewProvider('test-view', MockComponent); + + const view = result.current.getView('test-view'); + expect(view).not.toBeNull(); + + // Render the returned view to verify it's wrapped in ErrorBoundary and contains the component + const { getByTestId } = render(view!); + expect(getByTestId('mock-component')).toBeInTheDocument(); + expect(getByTestId('mock-component')).toHaveTextContent('Mock View Content'); +}); + +test('getView returns placeholder when no provider is registered', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useExtensionsContext(), { wrapper }); + + const view = result.current.getView('non-existent-view'); + expect(view).not.toBeNull(); + + const { getByText } = render(view); + expect( + getByText('The extension non-existent-view could not be loaded.'), + ).toBeInTheDocument(); + expect( + getByText(/This may be due to the extension not being activated/), + ).toBeInTheDocument(); +}); + +test('renders children correctly', () => { + const TestChild = () =>
Test Child
; + + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('test-child')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/extensions/ExtensionsContext.tsx b/superset-frontend/src/extensions/ExtensionsContext.tsx new file mode 100644 index 00000000000..dadea48fe23 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsContext.tsx @@ -0,0 +1,93 @@ +/** + * 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 { + createContext, + useContext, + useState, + ReactElement, + useEffect, + useMemo, +} from 'react'; +import { ErrorBoundary } from 'src/components'; +import { setExtensionsContextValue } from './ExtensionsContextUtils'; +import ExtensionPlaceholder from './ExtensionPlaceholder'; + +export interface ExtensionsContextType { + getView: (id: string) => ReactElement; + registerViewProvider: (id: string, viewProvider: () => ReactElement) => void; + unregisterViewProvider: (id: string) => void; +} + +const ExtensionsContext = createContext( + undefined, +); + +export const ExtensionsProvider: React.FC = ({ children }) => { + const [viewProviders, setViewProviders] = useState<{ + [id: string]: () => ReactElement; + }>({}); + + const registerViewProvider = ( + id: string, + viewProvider: () => ReactElement, + ) => { + setViewProviders(prev => ({ ...prev, [id]: viewProvider })); + }; + + const unregisterViewProvider = (id: string) => { + setViewProviders(prev => { + const { [id]: _, ...rest } = prev; + return rest; + }); + }; + + const getView = (id: string) => { + const viewProvider = viewProviders[id]; + if (viewProvider) { + return {viewProvider()}; + } + return ; + }; + + const contextValue = useMemo( + () => ({ getView, registerViewProvider, unregisterViewProvider }), + [viewProviders], + ); + + return ( + + {children} + + ); +}; + +export const useExtensionsContext = () => { + const context = useContext(ExtensionsContext); + if (!context) { + throw new Error( + 'useExtensionsContext must be used within a ExtensionsProvider', + ); + } + + useEffect(() => { + setExtensionsContextValue(context); + }, [context]); + + return context; +}; diff --git a/superset-frontend/src/extensions/ExtensionsContextUtils.test.ts b/superset-frontend/src/extensions/ExtensionsContextUtils.test.ts new file mode 100644 index 00000000000..ecd6b7950b0 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsContextUtils.test.ts @@ -0,0 +1,74 @@ +/** + * 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 { + setExtensionsContextValue, + getExtensionsContextValue, +} from './ExtensionsContextUtils'; +import { ExtensionsContextType } from './ExtensionsContext'; + +const mockExtensionsContext: ExtensionsContextType = { + getView: jest.fn(), + registerViewProvider: jest.fn(), + unregisterViewProvider: jest.fn(), +}; + +test('sets and gets extensions context value', () => { + setExtensionsContextValue(mockExtensionsContext); + const retrievedContext = getExtensionsContextValue(); + + expect(retrievedContext).toBe(mockExtensionsContext); + expect(retrievedContext.getView).toBe(mockExtensionsContext.getView); + expect(retrievedContext.registerViewProvider).toBe( + mockExtensionsContext.registerViewProvider, + ); + expect(retrievedContext.unregisterViewProvider).toBe( + mockExtensionsContext.unregisterViewProvider, + ); +}); + +test('throws error when getting context value before setting it', () => { + // Reset the context to null (this simulates initial state) + // We need to access the internal state, so we'll set it to a mock that will throw + expect(() => { + // Clear any previously set value by setting to null + (setExtensionsContextValue as any)(null); + getExtensionsContextValue(); + }).toThrow('ExtensionsContext value is not set'); +}); + +test('overwrites previous context value when setting new one', () => { + const firstContext: ExtensionsContextType = { + getView: jest.fn().mockReturnValue('first-view'), + registerViewProvider: jest.fn(), + unregisterViewProvider: jest.fn(), + }; + + const secondContext: ExtensionsContextType = { + getView: jest.fn().mockReturnValue('second-view'), + registerViewProvider: jest.fn(), + unregisterViewProvider: jest.fn(), + }; + + setExtensionsContextValue(firstContext); + expect(getExtensionsContextValue()).toBe(firstContext); + + setExtensionsContextValue(secondContext); + expect(getExtensionsContextValue()).toBe(secondContext); + expect(getExtensionsContextValue().getView).toBe(secondContext.getView); +}); diff --git a/superset-frontend/src/extensions/ExtensionsContextUtils.ts b/superset-frontend/src/extensions/ExtensionsContextUtils.ts new file mode 100644 index 00000000000..03dbdb71385 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsContextUtils.ts @@ -0,0 +1,32 @@ +/** + * 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 { ExtensionsContextType } from './ExtensionsContext'; + +let extensionsContextValue: ExtensionsContextType | null = null; + +export const setExtensionsContextValue = (value: ExtensionsContextType) => { + extensionsContextValue = value; +}; + +export const getExtensionsContextValue = () => { + if (!extensionsContextValue) { + throw new Error('ExtensionsContext value is not set'); + } + return extensionsContextValue; +}; diff --git a/superset-frontend/src/extensions/ExtensionsList.test.tsx b/superset-frontend/src/extensions/ExtensionsList.test.tsx new file mode 100644 index 00000000000..b877b34056f --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsList.test.tsx @@ -0,0 +1,99 @@ +/** + * 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 { render, waitFor } from 'spec/helpers/testing-library'; +import ExtensionsList from './ExtensionsList'; + +// Mock initial state for the store +const mockInitialState = { + extensions: { + loading: false, + resourceCount: 2, + resourceCollection: [ + { + id: 1, + name: 'Test Extension 1', + enabled: true, + contributions: + '{"menus": {"testMenu": {"primary": [{"key": "item1", "title": "Menu Item 1"}]}}, "views": {}}', + }, + { + id: 2, + name: 'Test Extension 2', + enabled: false, + contributions: + '{"commands": [{"command": "test.command", "title": "Test Command"}]}', + }, + ], + bulkSelectEnabled: false, + }, +}; + +const defaultProps = { + addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), +}; + +const renderWithStore = (props = {}) => + render(, { + useRedux: true, + useQueryParams: true, + useRouter: true, + useTheme: true, + initialState: mockInitialState, + }); + +test('renders extensions list with basic structure', async () => { + renderWithStore(); + + // Check that the component renders + expect(document.body).toBeInTheDocument(); +}); + +test('displays extension names in the list', async () => { + renderWithStore(); + + await waitFor(() => { + // These texts should appear somewhere in the rendered component + expect(document.body).toHaveTextContent(/Extensions/); + }); +}); + +test('displays contributions information', async () => { + renderWithStore(); + + await waitFor(() => { + // Should show contributions-related content + const bodyText = document.body.textContent || ''; + expect(bodyText).toMatch(/contribution/i); + }); +}); + +test('calls toast functions when provided', () => { + const addDangerToast = jest.fn(); + const addSuccessToast = jest.fn(); + + renderWithStore({ + addDangerToast, + addSuccessToast, + }); + + // The component should accept these props without error + expect(addDangerToast).toBeDefined(); + expect(addSuccessToast).toBeDefined(); +}); diff --git a/superset-frontend/src/extensions/ExtensionsList.tsx b/superset-frontend/src/extensions/ExtensionsList.tsx new file mode 100644 index 00000000000..5e7aeddacab --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsList.tsx @@ -0,0 +1,123 @@ +/** + * 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 { useTheme, css, t } from '@superset-ui/core'; +import { FunctionComponent, useMemo } from 'react'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import { ListView } from 'src/components'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import { JsonModal } from 'src/components/JsonModal'; +import { safeJsonObjectParse } from 'src/components/JsonModal/utils'; + +const PAGE_SIZE = 25; + +type Extension = { + id: number; + name: string; + enabled: boolean; +}; + +interface ExtensionsListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +const ExtensionsList: FunctionComponent = ({ + addDangerToast, + addSuccessToast, +}) => { + const theme = useTheme(); + + const { + state: { loading, resourceCount, resourceCollection }, + fetchData, + refreshData, + } = useListViewResource( + 'extensions', + t('Extensions'), + addDangerToast, + ); + + const columns = useMemo( + () => [ + { + Header: t('Name'), + accessor: 'name', + size: 'lg', + id: 'name', + Cell: ({ + row: { + original: { name }, + }, + }: any) => name, + }, + { + Header: t('Contributions'), + accessor: 'contributions', + size: 'lg', + id: 'contributions', + Cell: ({ + row: { + original: { contributions }, + }, + }: any) => ( +
+ +
+ ), + }, + ], + [loading], // We need to monitor loading to avoid stale state in actions + ); + + const menuData: SubMenuProps = { + activeChild: 'Extensions', + name: t('Extensions'), + buttons: [], + }; + + return ( + <> + + + columns={columns} + count={resourceCount} + data={resourceCollection} + initialSort={[{ id: 'name', desc: false }]} + pageSize={PAGE_SIZE} + fetchData={fetchData} + loading={loading} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + refreshData={refreshData} + /> + + ); +}; + +export default withToasts(ExtensionsList); diff --git a/superset-frontend/src/extensions/ExtensionsManager.test.ts b/superset-frontend/src/extensions/ExtensionsManager.test.ts new file mode 100644 index 00000000000..a3b06349b76 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsManager.test.ts @@ -0,0 +1,568 @@ +/** + * 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 fetchMock from 'fetch-mock'; +import type { contributions, core } from '@apache-superset/core'; +import ExtensionsManager from './ExtensionsManager'; + +// Type-safe mock data generators +interface MockExtensionOptions { + id?: string; + name?: string; + description?: string; + version?: string; + dependencies?: string[]; + remoteEntry?: string; + exposedModules?: string[]; + extensionDependencies?: string[]; + commands?: contributions.CommandContribution[]; + menus?: Record; + views?: Record; + includeMockFunctions?: boolean; +} + +/** + * Creates a mock extension with proper typing and default values + */ +function createMockExtension( + options: MockExtensionOptions = {}, +): core.Extension { + const { + id = 'test-extension', + name = 'Test Extension', + description = 'A test extension', + version = '1.0.0', + dependencies = [], + remoteEntry = '', + exposedModules = [], + extensionDependencies = [], + commands = [], + menus = {}, + views = {}, + includeMockFunctions = true, + } = options; + + const extension: core.Extension = { + id, + name, + description, + version, + dependencies, + remoteEntry, + exposedModules, + extensionDependencies, + contributions: { + commands, + menus, + views, + }, + activate: includeMockFunctions ? jest.fn() : undefined!, + deactivate: includeMockFunctions ? jest.fn() : undefined!, + }; + + return extension; +} + +/** + * Creates a mock command contribution with proper typing + */ +function createMockCommand( + command: string, + overrides: Partial = {}, +): contributions.CommandContribution { + return { + command, + icon: `${command}-icon`, + title: `${command} Command`, + description: `A ${command} command`, + ...overrides, + }; +} + +/** + * Creates a mock menu contribution with proper typing + */ +function createMockMenu( + overrides: Partial = {}, +): contributions.MenuContribution { + return { + context: [], + primary: [], + secondary: [], + ...overrides, + }; +} + +/** + * Creates a mock view contribution with proper typing + */ +function createMockView( + id: string, + overrides: Partial = {}, +): contributions.ViewContribution { + return { + id, + name: `${id} View`, + ...overrides, + }; +} + +/** + * Creates a mock menu item with proper typing + */ +function createMockMenuItem( + view: string, + command: string, + overrides: Partial = {}, +): contributions.MenuItem { + return { + view, + command, + ...overrides, + }; +} + +/** + * Sets up an activated extension in the manager by manually adding context and contributions + * This simulates what happens when an extension is properly enabled + */ +function setupActivatedExtension( + manager: ExtensionsManager, + extension: core.Extension, + contextOverrides: Partial<{ disposables: { dispose: () => void }[] }> = {}, +) { + const context = { disposables: [], ...contextOverrides }; + (manager as any).contextIndex.set(extension.id, context); + (manager as any).extensionContributions.set(extension.id, { + commands: extension.contributions.commands, + menus: extension.contributions.menus, + views: extension.contributions.views, + }); +} + +/** + * Creates a fully initialized and activated extension for testing + */ +async function createActivatedExtension( + manager: ExtensionsManager, + extensionOptions: MockExtensionOptions = {}, + contextOverrides: Partial<{ disposables: { dispose: () => void }[] }> = {}, +): Promise { + const mockExtension = createMockExtension({ + ...extensionOptions, + }); + + await manager.initializeExtension(mockExtension); + setupActivatedExtension(manager, mockExtension, contextOverrides); + + return mockExtension; +} + +/** + * Creates multiple activated extensions for testing + */ +async function createMultipleActivatedExtensions( + manager: ExtensionsManager, + extensionConfigs: MockExtensionOptions[], +): Promise { + const extensionPromises = extensionConfigs.map(config => + createActivatedExtension(manager, config), + ); + + return Promise.all(extensionPromises); +} + +/** + * Common assertions for deactivation success + */ +function expectSuccessfulDeactivation( + result: boolean, + mockExtension?: core.Extension, + expectedDeactivateCalls = 1, +) { + expect(result).toBe(true); + if (mockExtension && mockExtension.deactivate) { + expect(mockExtension.deactivate).toHaveBeenCalledTimes( + expectedDeactivateCalls, + ); + } +} + +/** + * Common assertions for deactivation failure + */ +function expectFailedDeactivation(result: boolean) { + expect(result).toBe(false); +} + +beforeEach(() => { + // Clear any existing instance + (ExtensionsManager as any).instance = undefined; + + // Setup fetch mocks for API calls + fetchMock.restore(); + fetchMock.put('glob:*/api/v1/extensions/*', { ok: true }); + fetchMock.delete('glob:*/api/v1/extensions/*', { ok: true }); + fetchMock.get('glob:*/api/v1/extensions/', { + json: { result: [] }, + }); + fetchMock.get('glob:*/api/v1/extensions/*', { + json: { + result: createMockExtension({ includeMockFunctions: false }), + }, + }); +}); + +afterEach(() => { + // Clean up after each test + (ExtensionsManager as any).instance = undefined; + fetchMock.restore(); +}); + +test('creates singleton instance', () => { + const manager1 = ExtensionsManager.getInstance(); + const manager2 = ExtensionsManager.getInstance(); + + expect(manager1).toBe(manager2); + expect(manager1).toBeInstanceOf(ExtensionsManager); +}); + +test('singleton maintains state across multiple getInstance calls', async () => { + const manager1 = ExtensionsManager.getInstance(); + const mockExtension = createMockExtension(); + + await manager1.initializeExtension(mockExtension); + + const manager2 = ExtensionsManager.getInstance(); + const extensions = manager2.getExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0]).toEqual(mockExtension); +}); + +test('returns empty array for getExtensions initially', () => { + const manager = ExtensionsManager.getInstance(); + const extensions = manager.getExtensions(); + + expect(Array.isArray(extensions)).toBe(true); + expect(extensions).toHaveLength(0); +}); +test('returns undefined for non-existent extension', () => { + const manager = ExtensionsManager.getInstance(); + const extension = manager.getExtension('non-existent-extension'); + + expect(extension).toBeUndefined(); +}); + +test('can store and retrieve extensions using initializeExtension', async () => { + const manager = ExtensionsManager.getInstance(); + const mockExtension = createMockExtension(); + + await manager.initializeExtension(mockExtension); + + const extensions = manager.getExtensions(); + expect(extensions).toHaveLength(1); + expect(extensions[0]).toEqual(mockExtension); + + const retrievedExtension = manager.getExtension('test-extension'); + expect(retrievedExtension).toEqual(mockExtension); +}); + +test('handles multiple extensions', async () => { + const manager = ExtensionsManager.getInstance(); + + const extension1 = createMockExtension({ + id: 'extension-1', + name: 'Extension 1', + }); + + const extension2 = createMockExtension({ + id: 'extension-2', + name: 'Extension 2', + }); + + await manager.initializeExtension(extension1); + await manager.initializeExtension(extension2); + + const extensions = manager.getExtensions(); + expect(extensions).toHaveLength(2); + + expect(manager.getExtension('extension-1')).toEqual(extension1); + expect(manager.getExtension('extension-2')).toEqual(extension2); + + expect(manager.getExtension('extension-1')?.name).toBe('Extension 1'); + expect(manager.getExtension('extension-2')?.name).toBe('Extension 2'); +}); + +test('initializeExtension properly stores extension in manager', async () => { + const manager = ExtensionsManager.getInstance(); + + const mockExtension = createMockExtension({ + id: 'test-extension-init', + name: 'Test Extension', + description: 'A test extension for initialization', + }); + + expect(manager.getExtension('test-extension-init')).toBeUndefined(); + expect(manager.getExtensions()).toHaveLength(0); + + await manager.initializeExtension(mockExtension); + + expect(manager.getExtension('test-extension-init')).toBeDefined(); + expect(manager.getExtensions()).toHaveLength(1); + expect(manager.getExtension('test-extension-init')?.name).toBe( + 'Test Extension', + ); + expect(manager.getExtension('test-extension-init')?.description).toBe( + 'A test extension for initialization', + ); +}); + +test('initializeExtension handles extension without remoteEntry', async () => { + const manager = ExtensionsManager.getInstance(); + + const mockExtension = createMockExtension({ + id: 'simple-extension', + name: 'Simple Extension', + description: 'Extension without remote entry', + remoteEntry: '', + commands: [createMockCommand('simple.command')], + }); + + expect(manager.getExtension('simple-extension')).toBeUndefined(); + + await manager.initializeExtension(mockExtension); + + expect(manager.getExtension('simple-extension')).toBeDefined(); + expect(manager.getExtensions()).toHaveLength(1); + expect(manager.getExtension('simple-extension')?.name).toBe( + 'Simple Extension', + ); + + // Since extension has no remoteEntry, activate should not be called + expect(mockExtension.activate).not.toHaveBeenCalled(); +}); + +test('getMenuContributions returns undefined initially', () => { + const manager = ExtensionsManager.getInstance(); + const menuContributions = manager.getMenuContributions('nonexistent'); + + expect(menuContributions).toBeUndefined(); +}); + +test('getViewContributions returns undefined initially', () => { + const manager = ExtensionsManager.getInstance(); + const viewContributions = manager.getViewContributions('nonexistent'); + + expect(viewContributions).toBeUndefined(); +}); + +test('getCommandContributions returns empty array initially', () => { + const manager = ExtensionsManager.getInstance(); + const commandContributions = manager.getCommandContributions(); + + expect(Array.isArray(commandContributions)).toBe(true); + expect(commandContributions).toHaveLength(0); +}); + +test('getCommandContribution returns undefined for non-existent command', () => { + const manager = ExtensionsManager.getInstance(); + const command = manager.getCommandContribution('nonexistent.command'); + + expect(command).toBeUndefined(); +}); + +test('deactivateExtension successfully deactivates an enabled extension', async () => { + const manager = ExtensionsManager.getInstance(); + const mockExtension = await createActivatedExtension(manager, { + commands: [createMockCommand('test.command')], + }); + + // Verify extension has contributions after setup + expect(manager.getCommandContributions()).toHaveLength(1); + + // Deactivate the extension + const result = manager.deactivateExtension('test-extension'); + + expectSuccessfulDeactivation(result, mockExtension); +}); + +test('deactivateExtension disposes of context disposables', async () => { + const manager = ExtensionsManager.getInstance(); + const mockDisposable = { dispose: jest.fn() }; + + await createActivatedExtension( + manager, + {}, + { + disposables: [mockDisposable], + }, + ); + + // Verify disposable is not yet disposed + expect(mockDisposable.dispose).not.toHaveBeenCalled(); + + // Deactivate the extension + const result = manager.deactivateExtension('test-extension'); + + expectSuccessfulDeactivation(result); + expect(mockDisposable.dispose).toHaveBeenCalledTimes(1); +}); + +test('deactivateExtension handles extension without deactivate function', async () => { + const manager = ExtensionsManager.getInstance(); + await createActivatedExtension(manager, { + includeMockFunctions: false, // Don't create mock functions + }); + + // Deactivate should still return true even without deactivate function + const result = manager.deactivateExtension('test-extension'); + + expectSuccessfulDeactivation(result); +}); + +test('deactivateExtension returns false for non-existent extension', () => { + const manager = ExtensionsManager.getInstance(); + + const result = manager.deactivateExtension('non-existent-extension'); + + expectFailedDeactivation(result); +}); + +test('deactivateExtension returns false for extension without context', async () => { + const manager = ExtensionsManager.getInstance(); + const mockExtension = createMockExtension({ + // Extension without context created + }); + + await manager.initializeExtension(mockExtension); + + const result = manager.deactivateExtension('test-extension'); + + expectFailedDeactivation(result); +}); + +test('deactivateExtension handles errors during deactivation gracefully', async () => { + const manager = ExtensionsManager.getInstance(); + const mockExtension = await createActivatedExtension(manager); + + // Override the deactivate function to throw an error + mockExtension.deactivate = jest.fn(() => { + throw new Error('Deactivation error'); + }); + + // Should return false when deactivation throws an error + const result = manager.deactivateExtension('test-extension'); + + expectFailedDeactivation(result); + expect(mockExtension.deactivate).toHaveBeenCalledTimes(1); +}); + +test('deactivateExtension handles errors during dispose gracefully', async () => { + const manager = ExtensionsManager.getInstance(); + const mockDisposable = { + dispose: jest.fn(() => { + throw new Error('Dispose error'); + }), + }; + + await createActivatedExtension( + manager, + {}, + { + disposables: [mockDisposable], + }, + ); + + // Should return false when disposal throws an error + const result = manager.deactivateExtension('test-extension'); + + expectFailedDeactivation(result); + expect(mockDisposable.dispose).toHaveBeenCalledTimes(1); +}); + +test('handles contributions with menu items', async () => { + const manager = ExtensionsManager.getInstance(); + + await createActivatedExtension(manager, { + commands: [ + createMockCommand('ext1.command1'), + createMockCommand('ext1.command2'), + ], + menus: { + testMenu: createMockMenu({ + primary: [ + createMockMenuItem('test-view', 'ext1.command1'), + createMockMenuItem('test-view2', 'ext1.command2'), + ], + secondary: [createMockMenuItem('test-view3', 'ext1.command1')], + }), + }, + views: { + testView: [createMockView('test-view-1'), createMockView('test-view-2')], + }, + }); + + // Test command contributions + const commands = manager.getCommandContributions(); + expect(commands).toHaveLength(2); + expect(commands.find(cmd => cmd.command === 'ext1.command1')).toBeDefined(); + expect(commands.find(cmd => cmd.command === 'ext1.command2')).toBeDefined(); + + // Test menu contributions + const menuContributions = manager.getMenuContributions('testMenu'); + expect(menuContributions).toBeDefined(); + expect(menuContributions?.primary).toHaveLength(2); + expect(menuContributions?.secondary).toHaveLength(1); + + // Test view contributions + const viewContributions = manager.getViewContributions('testView'); + expect(viewContributions).toBeDefined(); + expect(viewContributions).toHaveLength(2); +}); + +test('handles non-existent menu and view contributions', () => { + const manager = ExtensionsManager.getInstance(); + + expect(manager.getMenuContributions('nonexistent')).toBeUndefined(); + expect(manager.getViewContributions('nonexistent')).toBeUndefined(); + expect(manager.getCommandContribution('nonexistent.command')).toBeUndefined(); +}); + +test('merges contributions from multiple extensions', async () => { + const manager = ExtensionsManager.getInstance(); + + await createMultipleActivatedExtensions(manager, [ + { + id: 'extension-1', + name: 'Extension 1', + commands: [createMockCommand('ext1.command')], + }, + { + id: 'extension-2', + name: 'Extension 2', + commands: [createMockCommand('ext2.command')], + }, + ]); + + const commands = manager.getCommandContributions(); + expect(commands).toHaveLength(2); + + expect(manager.getCommandContribution('ext1.command')).toBeDefined(); + expect(manager.getCommandContribution('ext2.command')).toBeDefined(); +}); diff --git a/superset-frontend/src/extensions/ExtensionsManager.ts b/superset-frontend/src/extensions/ExtensionsManager.ts new file mode 100644 index 00000000000..6b2d3ef4d5a --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsManager.ts @@ -0,0 +1,329 @@ +/** + * 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 { + FeatureFlag, + SupersetClient, + isFeatureEnabled, + logging, +} from '@superset-ui/core'; +import type { contributions, core } from '@apache-superset/core'; +import { ExtensionContext } from '../core/core'; + +class ExtensionsManager { + private static instance: ExtensionsManager; + + private extensionIndex: Map = new Map(); + + private contextIndex: Map = new Map(); + + private extensionContributions: Map< + string, + { + menus?: Record; + views?: Record; + commands?: contributions.CommandContribution[]; + } + > = new Map(); + + // eslint-disable-next-line no-useless-constructor + private constructor() { + // Private constructor for singleton pattern + } + + /** + * Singleton instance getter. + * @returns The singleton instance of ExtensionsManager. + */ + public static getInstance(): ExtensionsManager { + if (!ExtensionsManager.instance) { + ExtensionsManager.instance = new ExtensionsManager(); + } + return ExtensionsManager.instance; + } + + /** + * Initializes extensions. + * @throws Error if initialization fails. + */ + public async initializeExtensions(): Promise { + if (!isFeatureEnabled(FeatureFlag.EnableExtensions)) { + return; + } + const response = await SupersetClient.get({ + endpoint: '/api/v1/extensions/', + }); + const extensions: core.Extension[] = response.json.result; + await Promise.all( + extensions.map(async extension => { + await this.initializeExtension(extension); + }), + ); + } + + /** + * Initializes an extension by its instance. + * If the extension has a remote entry, it will load the module. + * @param extension The extension to initialize. + */ + public async initializeExtension(extension: core.Extension) { + try { + let loadedExtension = extension; + if (extension.remoteEntry) { + loadedExtension = await this.loadModule(extension); + this.enableExtension(loadedExtension); + } + this.extensionIndex.set(loadedExtension.id, loadedExtension); + } catch (error) { + logging.error( + `Failed to initialize extension ${extension.name}\n`, + error, + ); + } + } + + /** + * Enables an extension by its instance. + * @param extension The extension to enable. + */ + private enableExtension(extension: core.Extension): void { + const { id } = extension; + if (extension && typeof extension.activate === 'function') { + // If already enabled, do nothing + if (this.contextIndex.has(id)) { + return; + } + const context = new ExtensionContext(); + this.contextIndex.set(id, context); + // TODO: Activate based on activation events + this.activateExtension(extension, context); + this.indexContributions(extension); + } + } + + /** + * Loads a single extension module. + * @param extension The extension to load. + * @returns The loaded extension with activate and deactivate methods. + */ + private async loadModule(extension: core.Extension): Promise { + const { remoteEntry, id, exposedModules } = extension; + + // Load the remote entry script + await new Promise((resolve, reject) => { + const element = document.createElement('script'); + element.src = remoteEntry; + element.type = 'text/javascript'; + element.async = true; + element.onload = () => resolve(); + element.onerror = ( + event: Event | string, + source?: string, + lineno?: number, + colno?: number, + error?: Error, + ) => { + const errorDetails = []; + if (source) errorDetails.push(`source: ${source}`); + if (lineno !== undefined) errorDetails.push(`line: ${lineno}`); + if (colno !== undefined) errorDetails.push(`column: ${colno}`); + if (error?.message) errorDetails.push(`error: ${error.message}`); + if (typeof event === 'string') errorDetails.push(`event: ${event}`); + + const detailsStr = + errorDetails.length > 0 ? `\n${errorDetails.join(', ')}` : ''; + const errorMessage = `Failed to load remote entry: ${remoteEntry}${detailsStr}`; + + return reject(new Error(errorMessage)); + }; + + document.head.appendChild(element); + }); + + // Initialize Webpack module federation + // @ts-ignore + await __webpack_init_sharing__('default'); + const container = (window as any)[id]; + + // @ts-ignore + await container.init(__webpack_share_scopes__.default); + + const factory = await container.get(exposedModules[0]); + const Module = factory(); + return { + ...extension, + activate: Module.activate, + deactivate: Module.deactivate, + }; + } + + /** + * Activates an extension if it has an activate method. + * @param extension The extension to activate. + * @param context The context to pass to the activate method. + */ + public activateExtension( + extension: core.Extension, + context: ExtensionContext, + ): void { + if (extension.activate) { + try { + extension.activate(context); + } catch (err) { + logging.warn(`Error activating ${extension.name}`, err); + } + } + } + + /** + * Deactivates an extension. + * @param id The id of the extension to deactivate. + * @returns True if deactivated, false otherwise. + */ + public deactivateExtension(id: string): boolean { + const extension = this.extensionIndex.get(id); + const context = extension ? this.contextIndex.get(extension.id) : undefined; + if (extension && context) { + try { + // Dispose of all disposables in the context + if (context.disposables) { + context.disposables.forEach(d => d.dispose()); + context.disposables = []; + } + if (typeof extension.deactivate === 'function') { + extension.deactivate(); + } + return true; + } catch (err) { + logging.warn(`Error deactivating ${extension.name}`, err); + } + } + return false; + } + + /** + * Indexes contributions from an extension for quick retrieval. + * @param extension The extension to index. + */ + private indexContributions(extension: core.Extension): void { + const { contributions, id } = extension; + this.extensionContributions.set(id, { + menus: contributions.menus, + views: contributions.views, + commands: contributions.commands, + }); + } + + /** + * Retrieves menu contributions for a specific key. + * @param key The key of the menu contributions. + * @returns The menu contributions matching the key, or undefined if not found. + */ + public getMenuContributions( + key: string, + ): contributions.MenuContribution | undefined { + const merged: contributions.MenuContribution = { + context: [], + primary: [], + secondary: [], + }; + for (const ext of this.extensionContributions.values()) { + if (ext.menus && ext.menus[key]) { + const menu = ext.menus[key]; + if (menu.context) merged.context!.push(...menu.context); + if (menu.primary) merged.primary!.push(...menu.primary); + if (menu.secondary) merged.secondary!.push(...menu.secondary); + } + } + if ( + (merged.context?.length ?? 0) === 0 && + (merged.primary?.length ?? 0) === 0 && + (merged.secondary?.length ?? 0) === 0 + ) { + return undefined; + } + return merged; + } + + /** + * Retrieves view contributions for a specific key. + * @param key The key of the view contributions. + * @returns An array of view contributions matching the key, or undefined if not found. + */ + public getViewContributions( + key: string, + ): contributions.ViewContribution[] | undefined { + let result: contributions.ViewContribution[] = []; + for (const ext of this.extensionContributions.values()) { + if (ext.views && ext.views[key]) { + result = result.concat(ext.views[key]); + } + } + return result.length > 0 ? result : undefined; + } + + /** + * Retrieves all command contributions. + * @returns An array of all command contributions. + */ + public getCommandContributions(): contributions.CommandContribution[] { + const result: contributions.CommandContribution[] = []; + for (const ext of this.extensionContributions.values()) { + if (ext.commands) { + result.push(...ext.commands); + } + } + return result; + } + + /** + * Retrieves a specific command contribution by its key. + * @param key The key of the command contribution. + * @returns The command contribution matching the key, or undefined if not found. + */ + public getCommandContribution( + key: string, + ): contributions.CommandContribution | undefined { + for (const ext of this.extensionContributions.values()) { + if (ext.commands) { + const found = ext.commands.find(cmd => cmd.command === key); + if (found) return found; + } + } + return undefined; + } + + /** + * Retrieves all extensions. + * @returns An array of all registered extensions. + */ + public getExtensions(): core.Extension[] { + return Array.from(this.extensionIndex.values()); + } + + /** + * Retrieves a specific extension by its id. + * @param id The id of the extension. + * @returns The extension matching the id, or undefined if not found. + */ + public getExtension(id: string): core.Extension | undefined { + return this.extensionIndex.get(id); + } +} + +export default ExtensionsManager; diff --git a/superset-frontend/src/extensions/ExtensionsStartup.test.tsx b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx new file mode 100644 index 00000000000..02010a4e5b9 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsStartup.test.tsx @@ -0,0 +1,205 @@ +/** + * 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 { render, waitFor } from 'spec/helpers/testing-library'; +import { logging } from '@superset-ui/core'; +import ExtensionsStartup from './ExtensionsStartup'; +import ExtensionsManager from './ExtensionsManager'; + +const mockInitialState = { + user: { userId: 1 }, +}; + +const mockInitialStateNoUser = { + user: { userId: undefined }, +}; + +// Clean up global state before each test +beforeEach(() => { + // Clear the window.superset object + delete (window as any).superset; + + // Clear any existing ExtensionsManager instance + (ExtensionsManager as any).instance = undefined; +}); + +afterEach(() => { + // Clean up after each test + delete (window as any).superset; + (ExtensionsManager as any).instance = undefined; +}); + +test('renders without crashing', () => { + render(, { + useRedux: true, + initialState: mockInitialState, + }); + + // Component renders null, so just check it doesn't throw + expect(true).toBe(true); +}); + +test('sets up global superset object when user is logged in', async () => { + render(, { + useRedux: true, + initialState: mockInitialState, + }); + + await waitFor(() => { + // Verify the global superset object is set up + expect((window as any).superset).toBeDefined(); + expect((window as any).superset.authentication).toBeDefined(); + expect((window as any).superset.core).toBeDefined(); + expect((window as any).superset.commands).toBeDefined(); + expect((window as any).superset.environment).toBeDefined(); + expect((window as any).superset.extensions).toBeDefined(); + expect((window as any).superset.sqlLab).toBeDefined(); + }); +}); + +test('does not set up global superset object when user is not logged in', async () => { + render(, { + useRedux: true, + initialState: mockInitialStateNoUser, + }); + + // Wait for the useEffect to complete and verify the global object is not set up + await waitFor(() => { + expect((window as any).superset).toBeUndefined(); + }); +}); + +test('initializes ExtensionsManager when user is logged in', async () => { + render(, { + useRedux: true, + initialState: mockInitialState, + }); + + await waitFor(() => { + // Verify ExtensionsManager has been initialized by checking if it has extensions loaded + const manager = ExtensionsManager.getInstance(); + // The manager should exist and be ready to use + expect(manager).toBeDefined(); + expect(manager.getExtensions).toBeDefined(); + }); +}); + +test('does not initialize ExtensionsManager when user is not logged in', async () => { + render(, { + useRedux: true, + initialState: mockInitialStateNoUser, + }); + + // Wait for the useEffect to complete and verify no initialization happened + await waitFor(() => { + const manager = ExtensionsManager.getInstance(); + expect(manager).toBeDefined(); + // Since no initialization happened, there should be no extensions loaded initially + expect(manager.getExtensions()).toEqual([]); + }); +}); + +test('handles ExtensionsManager initialization errors gracefully', async () => { + const errorSpy = jest.spyOn(logging, 'error').mockImplementation(); + + // Mock the initializeExtensions method to throw an error + const originalInitialize = ExtensionsManager.prototype.initializeExtensions; + ExtensionsManager.prototype.initializeExtensions = jest + .fn() + .mockImplementation(() => { + throw new Error('Test initialization error'); + }); + + render(, { + useRedux: true, + initialState: mockInitialState, + }); + + await waitFor(() => { + // Verify error was logged + expect(errorSpy).toHaveBeenCalledWith( + 'Error setting up extensions:', + expect.any(Error), + ); + }); + + // Restore original method + ExtensionsManager.prototype.initializeExtensions = originalInitialize; + errorSpy.mockRestore(); +}); + +test('logs success message when ExtensionsManager initializes successfully', async () => { + const infoSpy = jest.spyOn(logging, 'info').mockImplementation(); + + // Mock the initializeExtensions method to succeed + const originalInitialize = ExtensionsManager.prototype.initializeExtensions; + ExtensionsManager.prototype.initializeExtensions = jest + .fn() + .mockImplementation(() => Promise.resolve()); + + render(, { + useRedux: true, + initialState: mockInitialState, + }); + + await waitFor(() => { + // Verify success message was logged + expect(infoSpy).toHaveBeenCalledWith( + 'Extensions initialized successfully.', + ); + }); + + // Restore original method + ExtensionsManager.prototype.initializeExtensions = originalInitialize; + infoSpy.mockRestore(); +}); + +test('only initializes once even with multiple renders', async () => { + // Track calls to the manager's public API + const manager = ExtensionsManager.getInstance(); + const originalInitialize = manager.initializeExtensions; + let initializeCallCount = 0; + + manager.initializeExtensions = jest.fn().mockImplementation(() => { + initializeCallCount += 1; + return Promise.resolve(); + }); + + const { rerender } = render(, { + useRedux: true, + initialState: mockInitialState, + }); + + await waitFor(() => { + expect(initializeCallCount).toBe(1); + }); + + // Re-render the component + rerender(); + + // Wait for any potential async operations, but expect no additional calls + await waitFor(() => { + expect(initializeCallCount).toBe(1); + }); + + // Verify initialization is still only called once + expect(initializeCallCount).toBe(1); + + // Restore original method + manager.initializeExtensions = originalInitialize; +}); diff --git a/superset-frontend/src/extensions/ExtensionsStartup.tsx b/superset-frontend/src/extensions/ExtensionsStartup.tsx new file mode 100644 index 00000000000..2706bb73121 --- /dev/null +++ b/superset-frontend/src/extensions/ExtensionsStartup.tsx @@ -0,0 +1,91 @@ +/** + * 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 { useEffect, useState } from 'react'; +// eslint-disable-next-line no-restricted-syntax +import * as supersetCore from '@apache-superset/core'; +import { logging } from '@superset-ui/core'; +import { + authentication, + core, + commands, + environment, + extensions, + sqlLab, +} from 'src/core'; +import { useSelector } from 'react-redux'; +import { RootState } from 'src/views/store'; +import { useExtensionsContext } from './ExtensionsContext'; +import ExtensionsManager from './ExtensionsManager'; + +declare global { + interface Window { + superset: { + authentication: typeof authentication; + core: typeof core; + commands: typeof commands; + environment: typeof environment; + extensions: typeof extensions; + sqlLab: typeof sqlLab; + }; + } +} + +const ExtensionsStartup = () => { + // Initialize the extensions context before initializing extensions + // This is a prerequisite for the ExtensionsManager to work correctly + useExtensionsContext(); + + const [initialized, setInitialized] = useState(false); + + const userId = useSelector( + ({ user }) => user.userId, + ); + + useEffect(() => { + // Skip initialization if already initialized or if user is not logged in + if (initialized || !userId) { + return; + } + + // Provide the implementations for @apache-superset/core + window.superset = { + ...supersetCore, + authentication, + core, + commands, + environment, + extensions, + sqlLab, + }; + + // Initialize extensions + try { + ExtensionsManager.getInstance().initializeExtensions(); + logging.info('Extensions initialized successfully.'); + } catch (error) { + logging.error('Error setting up extensions:', error); + } finally { + setInitialized(true); + } + }, [initialized, userId]); + + return null; +}; + +export default ExtensionsStartup; diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx index c3fda7b7358..b29d03c9299 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.test.tsx @@ -23,8 +23,13 @@ import { EncryptedField, encryptedCredentialsMap } from './EncryptedField'; // Mock the useToasts hook jest.mock('src/components/MessageToasts/withToasts', () => ({ + __esModule: true, + default: (Component: any) => Component, useToasts: () => ({ addDangerToast: jest.fn(), + addSuccessToast: jest.fn(), + addInfoToast: jest.fn(), + addWarningToast: jest.fn(), }), })); diff --git a/superset-frontend/src/features/datasets/AddDataset/Footer/Footer.test.tsx b/superset-frontend/src/features/datasets/AddDataset/Footer/Footer.test.tsx index 04755ab1ad4..2c8ed77b394 100644 --- a/superset-frontend/src/features/datasets/AddDataset/Footer/Footer.test.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/Footer/Footer.test.tsx @@ -38,6 +38,10 @@ jest.mock('src/views/CRUD/hooks', () => ({ useSingleViewResource: () => ({ createResource: mockCreateResource, }), + getDatabaseDocumentationLinks: () => ({ + support: + 'https://superset.apache.org/docs/databases/installing-database-drivers', + }), })); const mockedProps = { diff --git a/superset-frontend/src/hooks/apiResources/queries.test.ts b/superset-frontend/src/hooks/apiResources/queries.test.ts index 14d1f9ceacd..76939756e6e 100644 --- a/superset-frontend/src/hooks/apiResources/queries.test.ts +++ b/superset-frontend/src/hooks/apiResources/queries.test.ts @@ -92,9 +92,17 @@ test('returns api response mapping camelCase keys', async () => { ...fakeApiResult, result: fakeApiResult.result.map(mapQueryResponse), }; - expect( - rison.decode(fetchMock.calls(editorQueryApiRoute)[0][0].split('?q=')[1]), - ).toEqual( + + // Check if the URL contains the expected rison-encoded parameters + const actualUrl = fetchMock.calls(editorQueryApiRoute)[0][0]; + expect(actualUrl).toContain('/api/v1/query/?q='); + + // Extract and decode the query parameter + const urlParams = new URL(actualUrl).searchParams; + const queryParam = urlParams.get('q'); + + expect(queryParam).toBeTruthy(); + expect(rison.decode(queryParam!)).toEqual( expect.objectContaining({ order_column: 'start_time', order_direction: 'desc', @@ -137,9 +145,17 @@ test('merges paginated results', async () => { await waitFor(() => expect(fetchMock.calls(editorQueryApiRoute).length).toBe(2), ); - expect( - rison.decode(fetchMock.calls(editorQueryApiRoute)[1][0].split('?q=')[1]), - ).toEqual( + + // Check the second call has page=1 + const secondUrl = fetchMock.calls(editorQueryApiRoute)[1][0]; + expect(secondUrl).toContain('/api/v1/query/?q='); + + // Extract and decode the query parameter + const urlParams = new URL(secondUrl).searchParams; + const queryParam = urlParams.get('q'); + + expect(queryParam).toBeTruthy(); + expect(rison.decode(queryParam!)).toEqual( expect.objectContaining({ page: 1, }), diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index 6c18cf76a87..88274eec4f1 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -39,6 +39,7 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils'; import setupExtensions from 'src/setup/setupExtensions'; import { logEvent } from 'src/logger/actions'; import { store } from 'src/views/store'; +import ExtensionsStartup from 'src/extensions/ExtensionsStartup'; import { RootContextProviders } from './RootContextProviders'; import { ScrollToTop } from './ScrollToTop'; @@ -75,6 +76,7 @@ const App = () => ( + { ReactRouterRoute={Route} stringifyOptions={{ encode: false }} > - {RootContextProviderExtension ? ( - - {children} - - ) : ( - children - )} + + {RootContextProviderExtension ? ( + + {children} + + ) : ( + children + )} + diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 262d05caa1f..db4fff7214c 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -127,6 +127,10 @@ const Tags = lazy( () => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'), ); +const Extensions = lazy( + () => import(/* webpackChunkName: "Tags" */ 'src/extensions/ExtensionsList'), +); + const RowLevelSecurityList = lazy( () => import( @@ -331,6 +335,13 @@ if (isAdmin) { Component: GroupsList, }, ); + + if (isFeatureEnabled(FeatureFlag.EnableExtensions)) { + routes.push({ + path: '/extensions/list/', + Component: Extensions, + }); + } } if (authRegistrationEnabled) { diff --git a/superset-frontend/src/views/store.ts b/superset-frontend/src/views/store.ts index 6498d4116e7..c200fdc9969 100644 --- a/superset-frontend/src/views/store.ts +++ b/superset-frontend/src/views/store.ts @@ -19,6 +19,7 @@ import { configureStore, ConfigureStoreOptions, + createListenerMiddleware, StoreEnhancer, } from '@reduxjs/toolkit'; import thunk from 'redux-thunk'; @@ -84,6 +85,8 @@ export const userReducer = ( return user; }; +export const listenerMiddleware = createListenerMiddleware(); + const getMiddleware: ConfigureStoreOptions['middleware'] = getDefaultMiddleware => process.env.REDUX_DEFAULT_MIDDLEWARE @@ -97,8 +100,8 @@ const getMiddleware: ConfigureStoreOptions['middleware'] = ignoredPaths: [/queryController/g], warnAfter: 200, }, - }).concat(logger, api.middleware) - : [thunk, logger, api.middleware]; + }).concat(listenerMiddleware.middleware, logger, api.middleware) + : [listenerMiddleware.middleware, thunk, logger, api.middleware]; // TODO: This reducer is a combination of the Dashboard and Explore reducers. // The correct way of handling this is to unify the actions and reducers from both diff --git a/superset-frontend/tsconfig.json b/superset-frontend/tsconfig.json index 91d2433fbaa..75c6acaeecb 100644 --- a/superset-frontend/tsconfig.json +++ b/superset-frontend/tsconfig.json @@ -16,14 +16,13 @@ ], "@superset-ui/plugin-chart-*": ["./plugins/plugin-chart-*/src"], "@superset-ui/preset-chart-*": ["./plugins/preset-chart-*/src"], - "@superset-ui/switchboard": ["./packages/superset-ui-switchboard/src"] + "@superset-ui/switchboard": ["./packages/superset-ui-switchboard/src"], + "@apache-superset/core": ["./packages/superset-core/src"] }, "typeRoots": ["src/types", "node_modules/@types"] }, - "exclude": [ - "./packages/generator-superset/test/**/*" - ], + "exclude": ["./packages/generator-superset/test/**/*"], "include": [ "./src/**/*", "./spec/**/*", diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 46e300b7557..94c0adf775c 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -20,6 +20,8 @@ const fs = require('fs'); const path = require('path'); const webpack = require('webpack'); + +const { ModuleFederationPlugin } = webpack.container; const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const CopyPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); @@ -141,6 +143,27 @@ const plugins = [ chunks: [], filename: '500.html', }), + new ModuleFederationPlugin({ + name: 'superset', + filename: 'remoteEntry.js', + shared: { + react: { + singleton: true, + eager: true, + requiredVersion: packageConfig.dependencies.react, + }, + 'react-dom': { + singleton: true, + eager: true, + requiredVersion: packageConfig.dependencies['react-dom'], + }, + antd: { + singleton: true, + requiredVersion: packageConfig.dependencies.antd, + eager: true, + }, + }, + }), ]; if (!process.env.CI) { @@ -516,7 +539,10 @@ Object.entries(packageConfig.dependencies).forEach(([pkg, relativeDir]) => { const srcPath = path.join(APP_DIR, `./node_modules/${pkg}/src`); const dir = relativeDir.replace('file:', ''); - if (/^@superset-ui/.test(pkg) && fs.existsSync(srcPath)) { + if ( + (/^@superset-ui/.test(pkg) || /^@apache-superset/.test(pkg)) && + fs.existsSync(srcPath) + ) { console.log(`[Superset Plugin] Use symlink source for ${pkg} @ ${dir}`); config.resolve.alias[pkg] = path.resolve(APP_DIR, `${dir}/src`); } diff --git a/superset/app.py b/superset/app.py index b1fec63cf30..a0517b773c8 100644 --- a/superset/app.py +++ b/superset/app.py @@ -33,10 +33,12 @@ else: if TYPE_CHECKING: from _typeshed.wsgi import StartResponse, WSGIApplication, WSGIEnvironment - from flask import Flask, Response from werkzeug.exceptions import NotFound +from superset.extensions.local_extensions_watcher import ( + start_local_extensions_watcher_thread, +) from superset.initialization import SupersetAppInitializer logger = logging.getLogger(__name__) @@ -72,6 +74,10 @@ def create_app( app_initializer = app.config.get("APP_INITIALIZER", SupersetAppInitializer)(app) app_initializer.init_app() + # Set up LOCAL_EXTENSIONS file watcher when in debug mode + if app.debug: + start_local_extensions_watcher_thread(app) + return app # Make sure that bootstrap errors ALWAYS get logged diff --git a/superset/config.py b/superset/config.py index 776accec45e..d80483762e6 100644 --- a/superset/config.py +++ b/superset/config.py @@ -618,6 +618,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { "AG_GRID_TABLE_ENABLED": False, # Enable Table v2 time comparison feature "TABLE_V2_TIME_COMPARISON_ENABLED": False, + # Enable Superset extensions, which allow users to add custom functionality + # to Superset without modifying the core codebase. + "ENABLE_EXTENSIONS": False, # Enable support for date range timeshifts (e.g., "2015-01-03 : 2015-01-04") # in addition to relative timeshifts (e.g., "1 day ago") "DATE_RANGE_TIMESHIFTS_ENABLED": False, @@ -1352,6 +1355,9 @@ ROBOT_PERMISSION_ROLES = ["Public", "Gamma", "Alpha", "Admin", "sql_lab"] CONFIG_PATH_ENV_VAR = "SUPERSET_CONFIG_PATH" +# Extension startup update configuration +EXTENSION_STARTUP_LOCK_TIMEOUT = 30 # Timeout in seconds for extension update locks + # If a callable is specified, it will be called at app startup while passing # a reference to the Flask app. This can be used to alter the Flask app # in whatever way. @@ -2163,6 +2169,9 @@ CATALOGS_SIMPLIFIED_MIGRATION: bool = False SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: bool = False +LOCAL_EXTENSIONS: list[str] = [] +EXTENSIONS_PATH: str | None = None + # ------------------------------------------------------------------- # * WARNING: STOP EDITING HERE * # ------------------------------------------------------------------- diff --git a/superset/core/__init__.py b/superset/core/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/core/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/core/api/__init__.py b/superset/core/api/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/core/api/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/core/api/types/__init__.py b/superset/core/api/types/__init__.py new file mode 100644 index 00000000000..13a83393a91 --- /dev/null +++ b/superset/core/api/types/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/core/api/types/models.py b/superset/core/api/types/models.py new file mode 100644 index 00000000000..001678987fd --- /dev/null +++ b/superset/core/api/types/models.py @@ -0,0 +1,72 @@ +# 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 Any, Type + +from flask_sqlalchemy import BaseQuery +from sqlalchemy.orm import scoped_session +from superset_core.api.types.models import CoreModelsApi + + +class HostModelsApi(CoreModelsApi): + @staticmethod + def get_session() -> scoped_session: + from superset import db + + return db.session + + @staticmethod + def get_dataset_model() -> Type[Any]: + """ + Retrieve the Dataset (SqlaTable) SQLAlchemy model. + """ + from superset.connectors.sqla.models import SqlaTable + + return SqlaTable + + @staticmethod + def get_database_model() -> Type[Any]: + """ + Retrieve the Database SQLAlchemy model. + """ + from superset.models.core import Database + + return Database + + @staticmethod + def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: + """ + Retrieve Dataset (SqlaTable) entities. + + :param query: A query with the Dataset model as the primary entity. + :returns: SqlaTable entities. + """ + from superset.daos.dataset import DatasetDAO + + if query: + return DatasetDAO.query(query) + + return DatasetDAO.filter_by(**kwargs) + + @staticmethod + def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]: + from superset.daos.database import DatabaseDAO + + if query: + return DatabaseDAO.query(query) + + return DatabaseDAO.filter_by(**kwargs) diff --git a/superset/core/api/types/query.py b/superset/core/api/types/query.py new file mode 100644 index 00000000000..0a0156153ec --- /dev/null +++ b/superset/core/api/types/query.py @@ -0,0 +1,29 @@ +# 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 Any + +from sqlglot import Dialects # pylint: disable=disallowed-sql-import +from superset_core.api.types.query import CoreQueryApi + +from superset.sql.parse import SQLGLOT_DIALECTS + + +class HostQueryApi(CoreQueryApi): + @staticmethod + def get_sqlglot_dialect(database: Any) -> Dialects: + return SQLGLOT_DIALECTS.get(database.backend) or Dialects.DIALECT diff --git a/superset/core/api/types/rest_api.py b/superset/core/api/types/rest_api.py new file mode 100644 index 00000000000..2d45556f6a3 --- /dev/null +++ b/superset/core/api/types/rest_api.py @@ -0,0 +1,35 @@ +# 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 Type + +from superset_core.api.types.rest_api import CoreRestApi, RestApi + +from superset.extensions import appbuilder + + +class HostRestApi(CoreRestApi): + @staticmethod + def add_api(api: Type[RestApi]) -> None: + view = appbuilder.add_api(api) + appbuilder._add_permission(view, True) + + @staticmethod + def add_extension_api(api: Type[RestApi]) -> None: + api.route_base = "/extensions/" + (api.resource_name or "") + view = appbuilder.add_api(api) + appbuilder._add_permission(view, True) diff --git a/superset/daos/base.py b/superset/daos/base.py index 189d53d4e26..0f8d8f388fc 100644 --- a/superset/daos/base.py +++ b/superset/daos/base.py @@ -21,6 +21,7 @@ from typing import Any, Generic, get_args, TypeVar from flask_appbuilder.models.filters import BaseFilter from flask_appbuilder.models.sqla import Model from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_sqlalchemy import BaseQuery from sqlalchemy.exc import SQLAlchemyError, StatementError from superset.daos.exceptions import ( @@ -196,3 +197,28 @@ class BaseDAO(Generic[T]): for item in items: db.session.delete(item) + + @classmethod + def query(cls, query: BaseQuery) -> list[T]: + """ + Get all that fit the `base_filter` based on a BaseQuery object + """ + if cls.base_filter: + data_model = SQLAInterface(cls.model_cls, db.session) + query = cls.base_filter( # pylint: disable=not-callable + cls.id_column_name, data_model + ).apply(query, None) + return query.all() + + @classmethod + def filter_by(cls, **filter_by: Any) -> list[T]: + """ + Get all entries that fit the `base_filter` + """ + query = db.session.query(cls.model_cls) + if cls.base_filter: + data_model = SQLAInterface(cls.model_cls, db.session) + query = cls.base_filter( # pylint: disable=not-callable + cls.id_column_name, data_model + ).apply(query, None) + return query.filter_by(**filter_by).all() diff --git a/superset/extensions/api.py b/superset/extensions/api.py new file mode 100644 index 00000000000..2368dce11bd --- /dev/null +++ b/superset/extensions/api.py @@ -0,0 +1,215 @@ +# 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 mimetypes +from io import BytesIO +from typing import Any + +from flask import send_file +from flask.wrappers import Response +from flask_appbuilder.api import BaseApi, expose, protect, safe + +from superset.extensions.utils import ( + build_extension_data, + get_extensions, +) + + +class ExtensionsRestApi(BaseApi): + allow_browser_login = True + resource_name = "extensions" + + def response(self, status_code: int, **kwargs: Any) -> Response: + """Helper method to create JSON responses.""" + from flask import jsonify + + return jsonify(kwargs), status_code + + def response_404(self) -> Response: + """Helper method to create 404 responses.""" + from flask import jsonify + + return jsonify({"message": "Not found"}), 404 + + @expose("/_info", methods=("GET",)) + @protect() + @safe + def info(self, **kwargs: Any) -> Response: + """Get API info including permissions. + --- + get: + summary: Get API info + responses: + 200: + description: API info + content: + application/json: + schema: + type: object + properties: + permissions: + type: array + items: + type: string + """ + return self.response(200, permissions=["can_read"]) + + # TODO: Support the q parameter + @protect() + @safe + @expose("/", methods=("GET",)) + def get_list(self, **kwargs: Any) -> Response: + """List all enabled extensions. + --- + get_list: + summary: List all enabled extensions. + responses: + 200: + description: List of all enabled extensions + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + type: object + properties: + remoteEntry: + type: string + remoteEntry: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + result = [] + extensions = get_extensions() + for extension in extensions.values(): + extension_data = build_extension_data(extension) + result.append(extension_data) + + response = { + "result": result, + "count": len(result), + } + + return self.response(200, **response) + + @protect() + @safe + @expose("/", methods=("GET",)) + def get(self, id: str, **kwargs: Any) -> Response: + """Get an extension by its id. + --- + get: + summary: Get an extension by its id. + parameters: + - in: path + schema: + type: string + name: id + responses: + 200: + description: Extension details + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + type: object + properties: + remoteEntry: + type: string + remoteEntry: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + extensions = get_extensions() + extension = extensions.get(id) + if not extension: + return self.response_404() + extension_data = build_extension_data(extension) + return self.response(200, result=extension_data) + + @protect() + @safe + @expose("//", methods=("GET",)) + def content(self, id: str, file: str) -> Response: + """Get a frontend chunk of an extension. + --- + get: + summary: Get a frontend chunk of an extension. + parameters: + - in: path + schema: + type: string + name: id + description: id of the extension + - in: path + schema: + type: string + name: file + description: name of the requested chunk + responses: + 200: + description: Extension import result + content: + application/json: + schema: + type: object + properties: + message: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + extensions = get_extensions() + extension = extensions.get(id) + if not extension: + return self.response_404() + + chunk = extension.frontend.get(file) + if not chunk: + return self.response_404() + + mimetype, _ = mimetypes.guess_type(file) + if not mimetype: + mimetype = "application/octet-stream" + + return send_file(BytesIO(chunk), mimetype=mimetype) diff --git a/superset/extensions/discovery.py b/superset/extensions/discovery.py new file mode 100644 index 00000000000..f1b00948645 --- /dev/null +++ b/superset/extensions/discovery.py @@ -0,0 +1,69 @@ +# 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 logging +import os +from pathlib import Path +from typing import Generator +from zipfile import is_zipfile, ZipFile + +from superset.extensions.types import LoadedExtension +from superset.extensions.utils import get_bundle_files_from_zip, get_loaded_extension + +logger = logging.getLogger(__name__) + + +def discover_and_load_extensions( + extensions_path: str, +) -> Generator[LoadedExtension, None, None]: + """ + Discover and load all .supx extension files from the specified path. + + Args: + extensions_path: Path to directory containing .supx extension files + + Yields: + LoadedExtension instances for each valid .supx file found + """ + if not extensions_path or not os.path.exists(extensions_path): + logger.warning(f"Extensions path does not exist or is empty: {extensions_path}") + return + + extensions_dir = Path(extensions_path) + + try: + # Look for .supx files only + for supx_file in extensions_dir.glob("*.supx"): + if not is_zipfile(supx_file): + logger.warning( + f"File has .supx extension but is not a valid zip file: {supx_file}" + ) + continue + + try: + with ZipFile(supx_file, "r") as zip_file: + files = get_bundle_files_from_zip(zip_file) + extension = get_loaded_extension(files) + extension_id = extension.manifest["id"] + logger.info(f"Loaded extension '{extension_id}' from {supx_file}") + yield extension + except Exception as e: + logger.error(f"Failed to load extension from {supx_file}: {e}") + continue + + except Exception as e: + logger.error(f"Error discovering extensions in {extensions_path}: {e}") diff --git a/superset/extensions/exceptions.py b/superset/extensions/exceptions.py new file mode 100644 index 00000000000..c9080ce0af0 --- /dev/null +++ b/superset/extensions/exceptions.py @@ -0,0 +1,48 @@ +# 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 flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ( + CommandException, + CreateFailedError, + DeleteFailedError, + UpdateFailedError, +) + + +class ExtensionCreateFailedError(CreateFailedError): + message = _("An error occurred while creating the extension.") + + +class ExtensionGetFailedError(CommandException): + message = _("An error occurred while accessing the extension.") + + +class ExtensionDeleteFailedError(DeleteFailedError): + message = _("An error occurred while deleting the extension.") + + +class ExtensionUpdateFailedError(UpdateFailedError): + message = _("An error occurred while updating the extension.") + + +class ExtensionUpsertFailedError(UpdateFailedError): + message = _("An error occurred while upserting the extension.") + + +class BundleValidationError(Exception): + pass diff --git a/superset/extensions/local_extensions_watcher.py b/superset/extensions/local_extensions_watcher.py new file mode 100644 index 00000000000..ae060a69cb9 --- /dev/null +++ b/superset/extensions/local_extensions_watcher.py @@ -0,0 +1,112 @@ +# 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. +"""Local extensions file watcher for development mode.""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from pathlib import Path +from typing import Any + +from flask import Flask +from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer + +logger = logging.getLogger(__name__) + + +class LocalExtensionFileHandler(FileSystemEventHandler): + """Custom file system event handler for LOCAL_EXTENSIONS directories.""" + + def on_any_event(self, event: Any) -> None: + """Handle any file system event in the watched directories.""" + if event.is_directory: + return + + logger.info(f"File change detected in LOCAL_EXTENSIONS: {event.src_path}") + + # Touch superset/__init__.py to trigger Flask's file watcher + superset_init = Path("superset/__init__.py") + logger.info(f"Triggering restart by touching {superset_init}") + os.utime(superset_init, (time.time(), time.time())) + + +def setup_local_extensions_watcher(app: Flask) -> None: # noqa: C901 + """Set up file watcher for LOCAL_EXTENSIONS directories.""" + # Only set up watcher in debug mode or when Flask reloader is enabled + if not (app.debug or app.config.get("FLASK_USE_RELOAD", False)): + return + + # Check if we're running under Flask's reloader to avoid conflicts + if os.environ.get("WERKZEUG_RUN_MAIN") == "true": + return + + local_extensions = app.config.get("LOCAL_EXTENSIONS", []) + if not local_extensions: + return + + # Collect dist directories to watch + watch_dirs = [] + for ext_path in local_extensions: + if not ext_path: + continue + + ext_path = Path(ext_path).resolve() + if not ext_path.exists(): + logger.warning(f"LOCAL_EXTENSIONS path does not exist: {ext_path}") + continue + + dist_path = ext_path / "dist" + watch_dirs.append(str(dist_path)) + logger.info(f"Watching LOCAL_EXTENSIONS dist directory: {dist_path}") + + if not watch_dirs: + return + + try: + # Set up and start the file watcher + event_handler = LocalExtensionFileHandler() + observer = Observer() + + for watch_dir in watch_dirs: + try: + observer.schedule(event_handler, watch_dir, recursive=True) + except Exception as e: + logger.warning(f"Failed to watch directory {watch_dir}: {e}") + continue + + observer.daemon = True + observer.start() + + logger.info( + f"LOCAL_EXTENSIONS file watcher started for {len(watch_dirs)} directories" # noqa: E501 + ) + + except Exception as e: + logger.error(f"Failed to start LOCAL_EXTENSIONS file watcher: {e}") + + +def start_local_extensions_watcher_thread(app: Flask) -> None: + """Start the LOCAL_EXTENSIONS file watcher in a daemon thread.""" + # Start setup in daemon thread if we're in main thread + if threading.current_thread() is threading.main_thread(): + threading.Thread( + target=lambda: setup_local_extensions_watcher(app), daemon=True + ).start() diff --git a/superset/extensions/types.py b/superset/extensions/types.py new file mode 100644 index 00000000000..07d7a317b6e --- /dev/null +++ b/superset/extensions/types.py @@ -0,0 +1,36 @@ +# 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 dataclasses import dataclass + +from superset_core.extensions.types import Manifest + + +@dataclass +class BundleFile: + name: str + content: bytes + + +@dataclass +class LoadedExtension: + id: str + name: str + manifest: Manifest + frontend: dict[str, bytes] + backend: dict[str, bytes] + version: str diff --git a/superset/extensions/utils.py b/superset/extensions/utils.py new file mode 100644 index 00000000000..8f058e0b967 --- /dev/null +++ b/superset/extensions/utils.py @@ -0,0 +1,219 @@ +# 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 importlib.abc +import importlib.util +import logging +import os +import re +import sys +from pathlib import Path +from typing import Any, Generator, Iterable, Tuple +from zipfile import ZipFile + +from flask import current_app +from superset_core.extensions.types import Manifest + +from superset.extensions.types import BundleFile, LoadedExtension +from superset.utils import json +from superset.utils.core import check_is_safe_zip + +logger = logging.getLogger(__name__) + +FRONTEND_REGEX = re.compile(r"^frontend/dist/([^/]+)$") +BACKEND_REGEX = re.compile(r"^backend/src/(.+)$") + + +class InMemoryLoader(importlib.abc.Loader): + def __init__( + self, module_name: str, source: str, is_package: bool, origin: str + ) -> None: + self.module_name = module_name + self.source = source + self.is_package = is_package + self.origin = origin + + def exec_module(self, module: Any) -> None: + module.__file__ = self.origin + module.__package__ = ( + self.module_name if self.is_package else self.module_name.rpartition(".")[0] + ) + if self.is_package: + module.__path__ = [] + exec(self.source, module.__dict__) # noqa: S102 + + +class InMemoryFinder(importlib.abc.MetaPathFinder): + def __init__(self, file_dict: dict[str, bytes]) -> None: + self.modules: dict[str, Tuple[Any, Any, Any]] = {} + for path, content in file_dict.items(): + mod_name, is_package = self._get_module_name(path) + self.modules[mod_name] = (content, is_package, path) + + def _get_module_name(self, file_path: str) -> Tuple[str, bool]: + parts = list(Path(file_path).parts) + is_package = parts[-1] == "__init__.py" + if is_package: + parts = parts[:-1] + else: + parts[-1] = Path(parts[-1]).stem + + mod_name = ".".join(parts) + return mod_name, is_package + + def find_spec(self, fullname: str, path: Any, target: Any = None) -> Any | None: + if fullname in self.modules: + source, is_package, origin = self.modules[fullname] + return importlib.util.spec_from_loader( + fullname, + InMemoryLoader(fullname, source, is_package, origin), + origin=origin, + is_package=is_package, + ) + return None + + +def install_in_memory_importer(file_dict: dict[str, bytes]) -> None: + finder = InMemoryFinder(file_dict) + sys.meta_path.insert(0, finder) + + +def eager_import(module_name: str) -> Any: + if module_name in sys.modules: + return sys.modules[module_name] + return importlib.import_module(module_name) + + +def get_bundle_files_from_zip(zip_file: ZipFile) -> Generator[BundleFile, None, None]: + check_is_safe_zip(zip_file) + for name in zip_file.namelist(): + content = zip_file.read(name) + yield BundleFile(name=name, content=content) + + +def get_bundle_files_from_path(base_path: str) -> Generator[BundleFile, None, None]: + dist_path = os.path.join(base_path, "dist") + + if not os.path.isdir(dist_path): + raise Exception(f"Expected directory {dist_path} does not exist.") + + for root, _, files in os.walk(dist_path): + for file in files: + full_path = os.path.join(root, file) + rel_path = os.path.relpath(full_path, dist_path).replace(os.sep, "/") + with open(full_path, "rb") as f: + content = f.read() + yield BundleFile(name=rel_path, content=content) + + +def get_loaded_extension(files: Iterable[BundleFile]) -> LoadedExtension: + manifest: Manifest = {} + frontend: dict[str, bytes] = {} + backend: dict[str, bytes] = {} + + for file in files: + filename = file.name + content = file.content + + if filename == "manifest.json": + try: + manifest = json.loads(content) + if "id" not in manifest: + raise Exception("Missing 'id' in manifest") + if "name" not in manifest: + raise Exception("Missing 'name' in manifest") + except Exception as e: + raise Exception(f"Invalid manifest.json: {e}") from e + + elif (match := FRONTEND_REGEX.match(filename)) is not None: + frontend[match.group(1)] = content + + elif (match := BACKEND_REGEX.match(filename)) is not None: + backend[match.group(1)] = content + + else: + raise Exception(f"Unexpected file in bundle: {filename}") + + id_ = manifest["id"] + name = manifest["name"] + version = manifest["version"] + return LoadedExtension( + id=id_, + name=name, + manifest=manifest, + frontend=frontend, + backend=backend, + version=version, + ) + + +def build_extension_data(extension: LoadedExtension) -> dict[str, Any]: + manifest: Manifest = extension.manifest + extension_data: dict[str, Any] = { + "id": manifest["id"], + "name": extension.name, + "version": extension.version, + "description": manifest.get("description", ""), + "dependencies": manifest.get("dependencies", []), + "extensionDependencies": manifest.get("extensionDependencies", []), + } + if frontend := manifest.get("frontend"): + module_federation = frontend.get("moduleFederation", {}) + remote_entry = frontend["remoteEntry"] + extension_data.update( + { + "remoteEntry": f"/api/v1/extensions/{manifest['id']}/{remote_entry}", # noqa: E501 + "exposedModules": module_federation.get("exposes", []), + "contributions": frontend.get("contributions", {}), + } + ) + return extension_data + + +def get_extensions() -> dict[str, LoadedExtension]: + extensions: dict[str, LoadedExtension] = {} + + # Load extensions from LOCAL_EXTENSIONS configuration (filesystem paths) + for path in current_app.config["LOCAL_EXTENSIONS"]: + files = get_bundle_files_from_path(path) + extension = get_loaded_extension(files) + extension_id = extension.manifest["id"] + extensions[extension_id] = extension + logger.info( + f"Loading extension {extension.name} (ID: {extension_id}) " + "from local filesystem" + ) + + # Load extensions from discovery path (.supx files) + if extensions_path := current_app.config.get("EXTENSIONS_PATH"): + from superset.extensions.discovery import discover_and_load_extensions + + for extension in discover_and_load_extensions(extensions_path): + extension_id = extension.manifest["id"] + if extension_id not in extensions: # Don't override LOCAL_EXTENSIONS + extensions[extension_id] = extension + logger.info( + f"Loading extension {extension.name} (ID: {extension_id}) " + "from discovery path" + ) + else: + logger.info( + f"Extension {extension.name} (ID: {extension_id}) already " + "loaded from LOCAL_EXTENSIONS, skipping discovery version" + ) + + return extensions diff --git a/superset/extensions/view.py b/superset/extensions/view.py new file mode 100644 index 00000000000..b3699163b68 --- /dev/null +++ b/superset/extensions/view.py @@ -0,0 +1,34 @@ +# 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 flask_appbuilder import expose +from flask_appbuilder.security.decorators import has_access, permission_name + +from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP +from superset.superset_typing import FlaskResponse +from superset.views.base import BaseSupersetView + + +class ExtensionsView(BaseSupersetView): + route_base = "/extensions" + class_permission_name = "Extensions" + method_permission_name = MODEL_VIEW_RW_METHOD_PERMISSION_MAP + + @expose("/list/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index e59b80fecff..cdecd96b92b 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -35,9 +35,13 @@ from flask_appbuilder.utils.base import get_safe_redirect from flask_babel import lazy_gettext as _, refresh from flask_compress import Compress from flask_session import Session +from superset_core import api as core_api from werkzeug.middleware.proxy_fix import ProxyFix from superset.constants import CHANGE_ME_SECRET_KEY +from superset.core.api.types.models import HostModelsApi +from superset.core.api.types.query import HostQueryApi +from superset.core.api.types.rest_api import HostRestApi from superset.databases.utils import make_url_safe from superset.extensions import ( _event_logger, @@ -171,6 +175,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.explore.api import ExploreRestApi from superset.explore.form_data.api import ExploreFormDataRestApi from superset.explore.permalink.api import ExplorePermalinkRestApi + from superset.extensions.view import ExtensionsView from superset.importexport.api import ImportExportRestApi from superset.queries.api import QueryRestApi from superset.queries.saved_queries.api import SavedQueryRestApi @@ -268,6 +273,12 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(SqlLabRestApi) appbuilder.add_api(SqlLabPermalinkRestApi) appbuilder.add_api(LogRestApi) + + if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"): + from superset.extensions.api import ExtensionsRestApi + + appbuilder.add_api(ExtensionsRestApi) + # # Setup regular views # @@ -390,6 +401,17 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category_icon="", ) + appbuilder.add_view( + ExtensionsView, + "Extensions", + label=_("Extensions"), + category="Manage", + category_label=_("Manage"), + menu_cond=lambda: feature_flag_manager.is_feature_enabled( + "ENABLE_EXTENSIONS" + ), + ) + # # Setup views with no menu # @@ -500,6 +522,42 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods icon="fa-lock", ) + def init_core_api(self) -> None: + global core_api + + core_api.models = HostModelsApi() + core_api.rest_api = HostRestApi() + core_api.query = HostQueryApi() + + def init_extensions(self) -> None: + from superset.extensions.utils import ( + eager_import, + get_extensions, + install_in_memory_importer, + ) + + try: + extensions = get_extensions() + except Exception: # pylint: disable=broad-except # noqa: S110 + # If the db hasn't been initialized yet, an exception will be raised. + # It's fine to ignore this, as in this case there are no extensions + # present yet. + return + + for extension in extensions.values(): + if backend_files := extension.backend: + install_in_memory_importer(backend_files) + + backend = extension.manifest.get("backend") + + if backend and (entrypoints := backend.get("entryPoints")): + for entrypoint in entrypoints: + try: + eager_import(entrypoint) + except Exception as ex: # pylint: disable=broad-except # noqa: S110 + # Surface exceptions during initialization of extensions + print(ex) + def init_app_in_ctx(self) -> None: """ Runs init logic in the context of the app @@ -523,6 +581,10 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods self.init_views() + if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"): + self.init_core_api() + self.init_extensions() + def check_secret_key(self) -> None: def log_default_secret_key_warning() -> None: top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-" diff --git a/superset/security/manager.py b/superset/security/manager.py index 7ac989e0b4c..b27874cdfa8 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -270,6 +270,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ADMIN_ONLY_VIEW_MENUS = { "Access Requests", "Action Logs", + "Extensions", "Log", "List Users", "UsersListView",