You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ms...@apache.org on 2026/09/02 17:28:30 UTC

(superset) branch master updated: feat(export): let deployments carry extra fields through asset export/import (#43763)

This is an automated email from the ASF dual-hosted git repository.

msyavuz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 3bd437869a6 feat(export): let deployments carry extra fields through asset export/import (#43763)
3bd437869a6 is described below

commit 3bd437869a68814af19408de349567702ebe740a
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Wed Sep 2 20:28:23 2026 +0300

    feat(export): let deployments carry extra fields through asset export/import (#43763)
---
 .../configuration/configuring-superset.mdx         | 49 +++++++++++
 superset/charts/schemas.py                         |  1 +
 superset/commands/chart/export.py                  |  8 +-
 superset/commands/chart/importers/v1/utils.py      |  8 +-
 superset/commands/dashboard/export.py              |  8 +-
 superset/commands/dashboard/importers/v1/utils.py  |  8 +-
 superset/commands/export/models.py                 | 15 +++-
 superset/commands/importers/v1/utils.py            | 15 ++++
 superset/config.py                                 |  8 ++
 superset/dashboards/schemas.py                     |  1 +
 .../unit_tests/commands/extra_asset_fields_test.py | 96 ++++++++++++++++++++++
 11 files changed, 212 insertions(+), 5 deletions(-)

diff --git a/docs/admin_docs/configuration/configuring-superset.mdx b/docs/admin_docs/configuration/configuring-superset.mdx
index f315715b21d..f30cdaedc7c 100644
--- a/docs/admin_docs/configuration/configuring-superset.mdx
+++ b/docs/admin_docs/configuration/configuring-superset.mdx
@@ -566,6 +566,55 @@ def FLASK_APP_MUTATOR(app: Flask) -> None:
     app.before_request_funcs.setdefault(None, []).append(make_session_permanent)
 ```
 
+## Carrying extra data through chart and dashboard exports
+
+Deployments often attach their own metadata to charts and dashboards — an owning
+team, a catalogue entry, a cost centre — and need it to survive an export/import
+round trip between environments. `EXTRA_ASSET_EXPORT_FIELDS` and
+`EXTRA_ASSET_IMPORT_HANDLER` let you do that without forking the export commands.
+
+The export hook receives the model and the asset type (`"chart"` or `"dashboard"`)
+and returns a mapping, which is serialised under the `extra` key of the asset's
+YAML. The import hook receives the model, the asset type and that same mapping,
+once the asset exists and has an id:
+
+```python
+# superset_config.py
+def _export_fields(model, asset_type):
+    return {"owning_team": lookup_team(model)}
+
+
+def _import_handler(model, asset_type, extra):
+    if team := extra.get("owning_team"):
+        assign_team(model, team)
+
+
+EXTRA_ASSET_EXPORT_FIELDS = _export_fields
+EXTRA_ASSET_IMPORT_HANDLER = _import_handler
+```
+
+The exported YAML then carries:
+
+```yaml
+slice_name: Revenue by region
+...
+extra:
+  owning_team: analytics-platform
+```
+
+A few things worth knowing:
+
+- **Both hooks are optional and default to `None`.** With neither configured,
+exported files are byte-for-byte what they were before, and imports behave
+identically.
+- **Everything lives under the single `extra` key.** The import schemas reject
+unknown top-level fields, so namespacing under `extra` keeps that strictness
+while leaving you free to change the shape of your own payload later.
+- **An export hook returning `None` or an empty mapping writes nothing**, so
+assets without your metadata do not gain an empty `extra` block.
+- **The import handler runs after the asset is created or updated**, which means
+you can rely on `model.id`. Raising from it will fail the import.
+
 ## Customizing the landing page (index view)
 
 The page served at `/` is rendered by an index view. By default Superset registers
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index a08217facca..3c6465fbbaf 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -1886,6 +1886,7 @@ class ImportV1ChartSchema(Schema):
     is_managed_externally = fields.Boolean(allow_none=True, dump_default=False)
     external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
     tags = fields.List(fields.String(), allow_none=True)
+    extra = fields.Dict(allow_none=True, load_only=True)
 
 
 class ChartCacheWarmUpRequestSchema(Schema):
diff --git a/superset/commands/chart/export.py b/superset/commands/chart/export.py
index 89a8a015b9f..83ab5ddf7ee 100644
--- a/superset/commands/chart/export.py
+++ b/superset/commands/chart/export.py
@@ -25,7 +25,10 @@ import yaml
 from superset.commands.chart.exceptions import ChartNotFoundError
 from superset.daos.chart import ChartDAO
 from superset.commands.dataset.export import ExportDatasetsCommand
-from superset.commands.export.models import ExportModelsCommand
+from superset.commands.export.models import (
+    ExportModelsCommand,
+    get_extra_export_fields,
+)
 from superset.commands.tag.export import ExportTagsCommand
 from superset.models.slice import Slice
 from superset.tags.models import TagType
@@ -78,6 +81,9 @@ class ExportChartsCommand(ExportModelsCommand):
         if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
             tags = getattr(model, "tags", [])
             payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
+        if extra_fields := get_extra_export_fields(model, "chart"):
+            payload["extra"] = extra_fields
+
         file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
         return file_content
 
diff --git a/superset/commands/chart/importers/v1/utils.py b/superset/commands/chart/importers/v1/utils.py
index 5488777a8a7..20e59cb2397 100644
--- a/superset/commands/chart/importers/v1/utils.py
+++ b/superset/commands/chart/importers/v1/utils.py
@@ -21,7 +21,10 @@ from typing import Any
 
 from superset import db, security_manager
 from superset.commands.exceptions import ImportFailedError
-from superset.commands.importers.v1.utils import find_existing_for_import
+from superset.commands.importers.v1.utils import (
+    apply_extra_import_fields,
+    find_existing_for_import,
+)
 from superset.migrations.shared.migrate_viz import processors
 from superset.migrations.shared.migrate_viz.base import MigrateViz
 from superset.models.slice import Slice
@@ -199,6 +202,7 @@ def import_chart(
     # migrate old viz types to new ones
     config = migrate_chart(config)
 
+    extra = config.pop("extra", None)
     chart = Slice.import_from_dict(config, recursive=False, allow_reparenting=True)
     if chart.id is None:
         db.session.flush()
@@ -227,6 +231,8 @@ def import_chart(
             if viewer not in chart.viewers:
                 chart.viewers.append(viewer)
 
+    apply_extra_import_fields(chart, "chart", extra)
+
     return chart
 
 
diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py
index 2618889e20b..940adb691e9 100644
--- a/superset/commands/dashboard/export.py
+++ b/superset/commands/dashboard/export.py
@@ -28,7 +28,10 @@ from superset.commands.tag.export import ExportTagsCommand
 from superset.commands.dashboard.exceptions import DashboardNotFoundError
 from superset.commands.dashboard.importers.v1.utils import find_chart_uuids
 from superset.daos.dashboard import DashboardDAO
-from superset.commands.export.models import ExportModelsCommand
+from superset.commands.export.models import (
+    ExportModelsCommand,
+    get_extra_export_fields,
+)
 from superset.commands.dataset.export import ExportDatasetsCommand
 from superset.daos.dataset import DatasetDAO
 from superset.models.dashboard import Dashboard
@@ -377,6 +380,9 @@ class ExportDashboardsCommand(ExportModelsCommand):
             tags = model.tags if hasattr(model, "tags") else []
             payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
 
+        if extra_fields := get_extra_export_fields(model, "dashboard"):
+            payload["extra"] = extra_fields
+
         file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
         return file_content
 
diff --git a/superset/commands/dashboard/importers/v1/utils.py b/superset/commands/dashboard/importers/v1/utils.py
index 9941eb123b6..ebd2f97d960 100644
--- a/superset/commands/dashboard/importers/v1/utils.py
+++ b/superset/commands/dashboard/importers/v1/utils.py
@@ -20,7 +20,10 @@ from typing import Any
 
 from superset import db, security_manager
 from superset.commands.exceptions import ImportFailedError
-from superset.commands.importers.v1.utils import find_existing_for_import
+from superset.commands.importers.v1.utils import (
+    apply_extra_import_fields,
+    find_existing_for_import,
+)
 from superset.daos.dashboard import DashboardDAO
 from superset.models.dashboard import Dashboard
 from superset.subjects.models import Subject
@@ -446,6 +449,7 @@ def import_dashboard(  # noqa: C901
             except TypeError:
                 logger.info("Unable to encode `%s` field: %s", key, value)
 
+    extra = config.pop("extra", None)
     dashboard = Dashboard.import_from_dict(config, recursive=False)
     if dashboard.id is None:
         db.session.flush()
@@ -470,4 +474,6 @@ def import_dashboard(  # noqa: C901
             if viewer not in dashboard.viewers:
                 dashboard.viewers.append(viewer)
 
+    apply_extra_import_fields(dashboard, "dashboard", extra)
+
     return dashboard
diff --git a/superset/commands/export/models.py b/superset/commands/export/models.py
index ead283fddb0..a3094c2fe69 100644
--- a/superset/commands/export/models.py
+++ b/superset/commands/export/models.py
@@ -17,9 +17,10 @@
 
 from collections.abc import Iterator
 from datetime import datetime, timezone
-from typing import Callable
+from typing import Any, Callable
 
 import yaml
+from flask import current_app
 from flask_appbuilder import Model
 
 from superset.commands.base import BaseCommand
@@ -30,6 +31,18 @@ from superset.utils.dict_import_export import EXPORT_VERSION
 METADATA_FILE_NAME = "metadata.yaml"
 
 
+def get_extra_export_fields(model: Model, asset_type: str) -> dict[str, Any]:
+    """Collect deployment-specific fields to serialise under ``extra``.
+
+    Returns an empty mapping when no ``EXTRA_ASSET_EXPORT_FIELDS`` hook is
+    configured, so exports are unchanged by default.
+    """
+    hook = current_app.config.get("EXTRA_ASSET_EXPORT_FIELDS")
+    if not hook:
+        return {}
+    return hook(model, asset_type) or {}
+
+
 class ExportModelsCommand(BaseCommand):
     dao: type[BaseDAO[Model]] = BaseDAO
     not_found: type[CommandException] = CommandException
diff --git a/superset/commands/importers/v1/utils.py b/superset/commands/importers/v1/utils.py
index 0217a3f3d31..bf599ffe4f1 100644
--- a/superset/commands/importers/v1/utils.py
+++ b/superset/commands/importers/v1/utils.py
@@ -19,6 +19,7 @@ from typing import Any, Callable, Dict, Optional, Type
 from zipfile import ZipFile
 
 import yaml
+from flask import current_app
 from marshmallow import fields, Schema, validate
 from marshmallow.exceptions import ValidationError
 from sqlalchemy.exc import SQLAlchemyError
@@ -594,3 +595,17 @@ def clear_soft_deleted_for_import(existing: Any) -> None:
     """
     db.session.delete(existing)
     db.session.flush()
+
+
+def apply_extra_import_fields(
+    model: Any, asset_type: str, extra: dict[str, Any] | None
+) -> None:
+    """Hand the exported ``extra`` mapping to the deployment's import handler.
+
+    No-op unless ``EXTRA_ASSET_IMPORT_HANDLER`` is configured, so imports are
+    unchanged by default. Called once the asset exists and has an id.
+    """
+    if not extra:
+        return
+    if handler := current_app.config.get("EXTRA_ASSET_IMPORT_HANDLER"):
+        handler(model, asset_type, extra)
diff --git a/superset/config.py b/superset/config.py
index 8e760d3cf06..bdfc1f05331 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -3154,6 +3154,14 @@ EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
 EXTRA_EDITORS_RESOLVER: Callable[..., list[Any]] | None = None
 # Post-create hook for charts/dashboards. Receives (model, asset_type).
 AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
+# Contribute extra fields to a chart or dashboard export. Receives
+# (model, asset_type) and returns a mapping serialised under the "extra" key of
+# the exported YAML. Lets deployments carry their own metadata through
+# export/import without forking the export commands.
+EXTRA_ASSET_EXPORT_FIELDS: Callable[[Any, str], dict[str, Any]] | None = None
+# Consume the "extra" mapping when importing a chart or dashboard. Receives
+# (model, asset_type, extra) once the asset exists.
+EXTRA_ASSET_IMPORT_HANDLER: Callable[[Any, str, dict[str, Any]], None] | None = None
 
 
 # The migrations that add catalog permissions might take a considerably long time
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 41ed68d79e5..f2f2e6e5113 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -603,6 +603,7 @@ class ImportV1DashboardSchema(Schema):
     roles = fields.List(fields.Raw(), allow_none=True, load_only=True)
     theme_uuid = fields.UUID(allow_none=True)
     theme_id = fields.Integer(allow_none=True)
+    extra = fields.Dict(allow_none=True, load_only=True)
 
 
 class EmbeddedDashboardConfigSchema(Schema):
diff --git a/tests/unit_tests/commands/extra_asset_fields_test.py b/tests/unit_tests/commands/extra_asset_fields_test.py
new file mode 100644
index 00000000000..cd655538dee
--- /dev/null
+++ b/tests/unit_tests/commands/extra_asset_fields_test.py
@@ -0,0 +1,96 @@
+# 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 unittest.mock import MagicMock
+
+from flask import current_app
+
+
+def test_extra_export_fields_absent_by_default() -> None:
+    """No hook configured leaves exports untouched."""
+    from superset.commands.export.models import get_extra_export_fields
+
+    current_app.config.pop("EXTRA_ASSET_EXPORT_FIELDS", None)
+    assert get_extra_export_fields(MagicMock(), "dashboard") == {}
+
+
+def test_extra_export_fields_uses_hook() -> None:
+    """The hook's mapping is returned and receives the asset type."""
+    from superset.commands.export.models import get_extra_export_fields
+
+    model = MagicMock()
+    hook = MagicMock(return_value={"owning_team": "analytics-platform"})
+    current_app.config["EXTRA_ASSET_EXPORT_FIELDS"] = hook
+    try:
+        assert get_extra_export_fields(model, "chart") == {
+            "owning_team": "analytics-platform"
+        }
+        hook.assert_called_once_with(model, "chart")
+    finally:
+        current_app.config.pop("EXTRA_ASSET_EXPORT_FIELDS", None)
+
+
+def test_extra_export_fields_tolerates_none() -> None:
+    """A hook returning None must not put a null under ``extra``."""
+    from superset.commands.export.models import get_extra_export_fields
+
+    current_app.config["EXTRA_ASSET_EXPORT_FIELDS"] = MagicMock(return_value=None)
+    try:
+        assert get_extra_export_fields(MagicMock(), "dashboard") == {}
+    finally:
+        current_app.config.pop("EXTRA_ASSET_EXPORT_FIELDS", None)
+
+
+def test_extra_import_handler_invoked() -> None:
+    """The handler receives the model, asset type and the extra mapping."""
+    from superset.commands.importers.v1.utils import apply_extra_import_fields
+
+    model = MagicMock()
+    handler = MagicMock()
+    current_app.config["EXTRA_ASSET_IMPORT_HANDLER"] = handler
+    try:
+        apply_extra_import_fields(
+            model, "dashboard", {"owning_team": "analytics-platform"}
+        )
+        handler.assert_called_once_with(
+            model, "dashboard", {"owning_team": "analytics-platform"}
+        )
+    finally:
+        current_app.config.pop("EXTRA_ASSET_IMPORT_HANDLER", None)
+
+
+def test_extra_import_handler_skipped_when_empty() -> None:
+    """An absent or empty ``extra`` never reaches the handler."""
+    from superset.commands.importers.v1.utils import apply_extra_import_fields
+
+    handler = MagicMock()
+    current_app.config["EXTRA_ASSET_IMPORT_HANDLER"] = handler
+    try:
+        apply_extra_import_fields(MagicMock(), "chart", None)
+        apply_extra_import_fields(MagicMock(), "chart", {})
+        handler.assert_not_called()
+    finally:
+        current_app.config.pop("EXTRA_ASSET_IMPORT_HANDLER", None)
+
+
+def test_import_schemas_accept_extra() -> None:
+    """``extra`` must survive schema validation, which defaults to RAISE."""
+    from superset.charts.schemas import ImportV1ChartSchema
+    from superset.dashboards.schemas import ImportV1DashboardSchema
+
+    assert "extra" in ImportV1DashboardSchema().fields
+    assert "extra" in ImportV1ChartSchema().fields