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