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/10/05 11:27:53 UTC
(superset) 04/12: feat(canvas): upgrade stored definitions and tolerate widget rule changes
This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch msyavuz/feat/canvas
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1cd3a9c3cc513937e981d854baf26c50e5e64c83
Author: Mehmet Salih Yavuz <sa...@proton.me>
AuthorDate: Thu Oct 1 12:38:18 2026 +0300
feat(canvas): upgrade stored definitions and tolerate widget rule changes
Stored definitions are upgraded to the current schema version on load
through a chain of per-version upgrades; the next write stores the
upgraded form and restarts the operation log, so clients on an older
base reload. A definition newer than the server supports is refused
with a 422 that says Superset needs upgrading.
Widget rules (nesting, sizes, child layouts, filter roles) are enforced
only on the nodes a write touches. Nodes saved under rules that have
since tightened keep their stored placement and no longer block edits
elsewhere on the canvas.
---
superset/canvas/api.py | 3 ++
superset/canvas/definition/ops.py | 5 ++-
superset/canvas/definition/render.py | 17 +++++++-
superset/canvas/definition/upgrades.py | 74 ++++++++++++++++++++++++++++++++
superset/canvas/definition/validation.py | 45 +++++++++++++++----
superset/commands/canvas/apply_ops.py | 9 +++-
superset/commands/canvas/create.py | 11 ++++-
superset/daos/canvas.py | 16 ++++---
tests/unit_tests/canvas/api_test.py | 30 +++++++++++++
tests/unit_tests/canvas/ops_test.py | 50 +++++++++++++++++++++
tests/unit_tests/canvas/upgrades_test.py | 62 ++++++++++++++++++++++++++
11 files changed, 301 insertions(+), 21 deletions(-)
diff --git a/superset/canvas/api.py b/superset/canvas/api.py
index 2c73de6e1fd..e365154663c 100644
--- a/superset/canvas/api.py
+++ b/superset/canvas/api.py
@@ -40,6 +40,7 @@ from superset.canvas.definition.schemas import (
Operation,
)
from superset.canvas.definition.scopes import resolve_filter_scopes
+from superset.canvas.definition.upgrades import DefinitionVersionError
from superset.canvas.filters import (
CanvasAccessFilter,
CanvasAllTextFilter,
@@ -94,6 +95,8 @@ def _handle_canvas_errors(
return self.response_422(message=ex.normalized_messages())
except (DefinitionConflictError, DefinitionInvalidError) as ex:
return self.response(ex.status, **ex.to_payload())
+ except DefinitionVersionError as ex:
+ return self.response_422(message=str(ex))
except (
CanvasCreateFailedError,
CanvasUpdateFailedError,
diff --git a/superset/canvas/definition/ops.py b/superset/canvas/definition/ops.py
index 64fb3b10df0..e17e964cb19 100644
--- a/superset/canvas/definition/ops.py
+++ b/superset/canvas/definition/ops.py
@@ -277,4 +277,7 @@ def apply_operations(
applied.append(applier.apply(op))
except ValueError as ex:
raise OperationError(index, str(ex)) from ex
- return normalize_definition(applier.canvas, rules, resolver), applied
+ # Widget rules are enforced on what these operations touched; the rest of
+ # the canvas keeps its stored placement even if a rule changed since.
+ touched = {touch.node_id for op in applied for touch in op.touched}
+ return normalize_definition(applier.canvas, rules, resolver, touched), applied
diff --git a/superset/canvas/definition/render.py b/superset/canvas/definition/render.py
index 49e9497df38..069ac2cc751 100644
--- a/superset/canvas/definition/render.py
+++ b/superset/canvas/definition/render.py
@@ -30,7 +30,8 @@ from __future__ import annotations
from typing import Any
-from superset_core.canvas import WidgetResolver
+from pydantic import ValidationError
+from superset_core.canvas import GridPlacement, WidgetResolver
from superset.canvas.definition.grid import resolve_grid
from superset.canvas.definition.registry import (
@@ -70,7 +71,8 @@ def render_context(
placements: dict[str, dict[str, int]] = {}
for children, columns in grids:
- rects, _ = resolve_grid([nodes[c]["layout"] for c in children], columns)
+ layouts = [_grid_layout(nodes[c]["layout"]) for c in children]
+ rects, _ = resolve_grid(layouts, columns)
for child, rect in zip(children, rects, strict=True):
placements[child] = {
"col": rect.col,
@@ -83,3 +85,14 @@ def render_context(
"placements": placements,
"gridColumns": grid_columns,
}
+
+
+def _grid_layout(layout: dict[str, Any]) -> dict[str, Any]:
+ """A stored layout as grid placement; one kept from another layout model
+ after a container's rules changed is auto-placed instead."""
+ try:
+ return GridPlacement.model_validate(layout).model_dump(
+ mode="json", by_alias=True, exclude_none=True
+ )
+ except ValidationError:
+ return {}
diff --git a/superset/canvas/definition/upgrades.py b/superset/canvas/definition/upgrades.py
new file mode 100644
index 00000000000..1f63cc8b020
--- /dev/null
+++ b/superset/canvas/definition/upgrades.py
@@ -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.
+"""
+Upgrading stored definitions to the current schema version.
+
+Every change to the definition schema bumps ``DEFINITION_VERSION`` and adds an
+upgrade from the previous version to ``UPGRADES``. Definitions are upgraded
+when loaded; the next write stores the upgraded form. A definition newer than
+this server understands is refused rather than read with a partial schema.
+"""
+
+from __future__ import annotations
+
+import copy
+from collections.abc import Callable, Mapping
+from typing import Any
+
+from superset.canvas.definition.schemas import DEFINITION_VERSION
+
+Upgrade = Callable[[dict[str, Any]], dict[str, Any]]
+
+# Upgrades keyed by the version they upgrade from; each returns the definition
+# at the next version.
+UPGRADES: dict[int, Upgrade] = {}
+
+
+class DefinitionVersionError(ValueError):
+ def __init__(self, version: object, current: int) -> None:
+ self.version = version
+ self.current = current
+ if isinstance(version, int) and version > current:
+ message = (
+ f"This canvas was saved with definition version {version}; this "
+ f"Superset supports up to {current}. Upgrade Superset to open it."
+ )
+ else:
+ message = f"Unsupported canvas definition version {version!r}"
+ super().__init__(message)
+
+
+def upgrade_definition(
+ definition: dict[str, Any],
+ current: int = DEFINITION_VERSION,
+ upgrades: Mapping[int, Upgrade] | None = None,
+) -> dict[str, Any]:
+ """Return ``definition`` at version ``current``, upgrading it if older."""
+ upgrades = UPGRADES if upgrades is None else upgrades
+ version = definition.get("version", 1)
+ if not isinstance(version, int) or isinstance(version, bool):
+ raise DefinitionVersionError(version, current)
+ if version > current or version < 1:
+ raise DefinitionVersionError(version, current)
+ if version == current:
+ return definition
+ upgraded = copy.deepcopy(definition)
+ while version < current:
+ upgraded = upgrades[version](upgraded)
+ version += 1
+ upgraded["version"] = version
+ return upgraded
diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py
index d4f70617ea7..ec4b342a940 100644
--- a/superset/canvas/definition/validation.py
+++ b/superset/canvas/definition/validation.py
@@ -25,10 +25,16 @@ A node whose widget no longer exists, or whose container type is no longer
registered (e.g. its extension was removed), is kept as an unresolved
placeholder: it and its children stay as stored, so the rest of the canvas can
still be edited. Whether a new widget may be placed is checked by the caller.
+
+Widget types' rules (nesting, sizes, child layouts, filter roles) can change
+after a canvas is saved. With ``strict_nodes`` given, only those nodes are
+checked against the rules; the others keep their stored placement, so a
+tightened rule never blocks edits elsewhere. Tree integrity is always checked.
"""
from __future__ import annotations
+from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
@@ -81,10 +87,12 @@ class _Validator:
raw: dict[str, Any],
rules: LayoutRulesRegistry,
resolver: WidgetResolver,
+ strict_nodes: Iterable[str] | None,
) -> None:
self.raw = raw
self.rules = rules
self.resolver = resolver
+ self.strict = None if strict_nodes is None else set(strict_nodes)
self.issues: list[Issue] = []
self.parents: dict[str, str] = {}
self.node_rules: dict[str, type[CanvasLayoutRules]] = {}
@@ -94,6 +102,9 @@ class _Validator:
def fail(self, path: str, message: str) -> None:
self.issues.append(Issue(path, message))
+ def is_strict(self, node_id: str) -> bool:
+ return self.strict is None or node_id in self.strict
+
def run(self) -> dict[str, Any]:
try:
canvas = CanvasDefinition.model_validate(self.raw)
@@ -179,7 +190,7 @@ class _Validator:
def _check_nesting(self) -> None:
for node_id, parent_id in self.parents.items():
rules = self.node_rules.get(node_id)
- if rules is None:
+ if rules is None or not self.is_strict(node_id):
continue
accepted: frozenset[str] | None = None
if parent_id == ROOT_ID:
@@ -206,8 +217,9 @@ class _Validator:
if filter_id not in nodes:
self.fail(pointer(*path), f"unknown node {filter_id!r}")
continue
+ strict = self.is_strict(filter_id)
rules = self.node_rules.get(filter_id)
- if rules is not None and not rules.is_filter:
+ if strict and rules is not None and not rules.is_filter:
self.fail(pointer(*path), f"node {filter_id!r} is not a filter")
for field in ("targets", "exclude"):
for index, target in enumerate(scope[field]):
@@ -216,7 +228,11 @@ class _Validator:
self.fail(
pointer(*path, field, index), f"unknown node {target!r}"
)
- elif target_rules is not None and not target_rules.is_filterable:
+ elif (
+ strict
+ and target_rules is not None
+ and not target_rules.is_filterable
+ ):
self.fail(
pointer(*path, field, index),
f"node {target!r} cannot be filtered",
@@ -255,16 +271,24 @@ class _Validator:
model = GridPlacement if columns is not None else child_model
assert model is not None # noqa: S101
layouts: list[dict[str, Any]] = []
+ # Children whose stored layout no longer fits a changed rule, kept as
+ # stored and auto-placed when resolving the grid.
+ kept: set[str] = set()
for child_id in children:
layout_path = ("nodes", child_id, "layout")
+ strict = self.is_strict(child_id)
try:
layout = model.model_validate(
doc["nodes"][child_id]["layout"]
).model_dump(mode="json", by_alias=True, exclude_none=True)
except ValidationError as ex:
- self.issues.extend(_pydantic_issues(layout_path, ex))
+ if strict:
+ self.issues.extend(_pydantic_issues(layout_path, ex))
+ continue
+ kept.add(child_id)
+ layouts.append({})
continue
- if columns is not None:
+ if columns is not None and strict:
for message in [
*span_errors(layout, columns),
*self._size_errors(child_id, layout, columns),
@@ -276,7 +300,8 @@ class _Validator:
if columns is not None and len(layouts) == len(children):
_, stored = resolve_grid(layouts, columns)
for child_id, layout in zip(children, stored, strict=True):
- doc["nodes"][child_id]["layout"] = layout
+ if child_id not in kept:
+ doc["nodes"][child_id]["layout"] = layout
def _size_errors(
self, node_id: str, layout: dict[str, Any], columns: int
@@ -309,13 +334,15 @@ def normalize_definition(
raw: dict[str, Any],
rules: LayoutRulesRegistry | None = None,
resolver: WidgetResolver | None = None,
+ strict_nodes: Iterable[str] | None = None,
) -> dict[str, Any]:
"""
Validate ``raw`` and return the normalized canvas to store.
- Raises ``DefinitionValidationError`` listing every problem found, each with a
- JSON-pointer path into the canvas.
+ ``strict_nodes`` limits the widget rule checks to those nodes; ``None``
+ checks every node. Raises ``DefinitionValidationError`` listing every
+ problem found, each with a JSON-pointer path into the canvas.
"""
return _Validator(
- raw, rules or layout_rules, resolver or get_widget_resolver()
+ raw, rules or layout_rules, resolver or get_widget_resolver(), strict_nodes
).run()
diff --git a/superset/commands/canvas/apply_ops.py b/superset/commands/canvas/apply_ops.py
index 9e4d1e7268b..790af1883b6 100644
--- a/superset/commands/canvas/apply_ops.py
+++ b/superset/commands/canvas/apply_ops.py
@@ -35,7 +35,7 @@ from superset.canvas.definition.ops import (
)
from superset.canvas.definition.registry import get_widget_resolver
from superset.canvas.definition.render import render_context
-from superset.canvas.definition.schemas import AddOp, Operation
+from superset.canvas.definition.schemas import AddOp, DEFINITION_VERSION, Operation
from superset.canvas.definition.scopes import resolve_filter_scopes
from superset.canvas.definition.validation import (
DefinitionValidationError,
@@ -111,7 +111,12 @@ class ApplyCanvasOperationsCommand(BaseCommand):
self.validate()
canvas = CanvasDAO.lock(self._canvas_id)
current = canvas.revision
+ # Logged operations predate the stored definition's upgrade, so they
+ # can't be checked for overlap; the log restarts with this write.
+ upgrading = canvas.definition_version != DEFINITION_VERSION
if self._base_revision != current:
+ if upgrading:
+ raise DefinitionConflictError(current, [], stale=True)
self._check_overlap(current, named_touches(self._ops))
try:
@@ -130,6 +135,8 @@ class ApplyCanvasOperationsCommand(BaseCommand):
canvas.definition = json.dumps(definition)
canvas.definition_version = definition["version"]
canvas.revision = revision
+ if upgrading:
+ CanvasDAO.prune(canvas.id)
CanvasDAO.log(canvas.id, revision, applied, get_user_id())
CanvasDAO.prune(canvas.id, revision - OP_LOG_RETAINED_REVISIONS)
return ApplyResult(
diff --git a/superset/commands/canvas/create.py b/superset/commands/canvas/create.py
index 6b5b856c8ab..d02af52e913 100644
--- a/superset/commands/canvas/create.py
+++ b/superset/commands/canvas/create.py
@@ -25,6 +25,10 @@ from marshmallow import ValidationError
from superset.canvas.definition.registry import get_widget_resolver
from superset.canvas.definition.schemas import empty_definition
+from superset.canvas.definition.upgrades import (
+ DefinitionVersionError,
+ upgrade_definition,
+)
from superset.canvas.definition.validation import (
DefinitionValidationError,
normalize_definition,
@@ -61,7 +65,12 @@ class CreateCanvasCommand(CreateMixin, BaseCommand):
def _validate_definition(self, exceptions: list[ValidationError]) -> None:
raw = self._properties.pop("definition", None) or empty_definition()
try:
- definition = normalize_definition(raw)
+ definition = normalize_definition(upgrade_definition(raw))
+ except DefinitionVersionError as ex:
+ exceptions.append(
+ ValidationError({"/version": [str(ex)]}, field_name="definition")
+ )
+ return
except DefinitionValidationError as ex:
exceptions.append(
ValidationError(
diff --git a/superset/daos/canvas.py b/superset/daos/canvas.py
index 069f66f3bbc..f4c6a810900 100644
--- a/superset/daos/canvas.py
+++ b/superset/daos/canvas.py
@@ -24,6 +24,7 @@ from sqlalchemy import func
from superset import db
from superset.canvas.definition.ops import AppliedOperation
+from superset.canvas.definition.upgrades import upgrade_definition
from superset.canvas.filters import CanvasAccessFilter
from superset.daos.base import BaseDAO
from superset.models.canvas import Canvas, CanvasOp
@@ -46,7 +47,8 @@ class CanvasDAO(BaseDAO[Canvas]):
@staticmethod
def load(canvas: Canvas) -> dict[str, Any]:
- return json.loads(canvas.definition)
+ """The stored definition, upgraded to the current schema version."""
+ return upgrade_definition(json.loads(canvas.definition))
@staticmethod
def ops_since(canvas_id: int, revision: int) -> list[CanvasOp]:
@@ -88,9 +90,9 @@ class CanvasDAO(BaseDAO[Canvas]):
)
@staticmethod
- def prune(canvas_id: int, keep_after_revision: int) -> None:
- """Drop logged operations at or before ``keep_after_revision``."""
- db.session.query(CanvasOp).filter(
- CanvasOp.canvas_id == canvas_id,
- CanvasOp.revision <= keep_after_revision,
- ).delete(synchronize_session=False)
+ def prune(canvas_id: int, keep_after_revision: int | None = None) -> None:
+ """Drop logged operations at or before ``keep_after_revision``, or all."""
+ query = db.session.query(CanvasOp).filter(CanvasOp.canvas_id == canvas_id)
+ if keep_after_revision is not None:
+ query = query.filter(CanvasOp.revision <= keep_after_revision)
+ query.delete(synchronize_session=False)
diff --git a/tests/unit_tests/canvas/api_test.py b/tests/unit_tests/canvas/api_test.py
index 4a09f277abd..18185df60d4 100644
--- a/tests/unit_tests/canvas/api_test.py
+++ b/tests/unit_tests/canvas/api_test.py
@@ -308,6 +308,36 @@ def test_changes_since_a_pruned_revision_are_stale(
assert client.get(f"{URL}/changes?since=3").json["result"]["changes"] == []
+def test_definition_from_a_newer_superset_is_refused(
+ client: Any, full_api_access: None, canvas: Any, published: MagicMock
+) -> None:
+ canvas.definition = json.dumps({**json.loads(canvas.definition), "version": 99})
+
+ for response in (client.get(URL), patch_ops(client, 1, add("chart-1"))):
+ assert response.status_code == 422
+ assert "Upgrade Superset" in response.json["message"]
+ assert canvas.revision == 1
+
+
+def test_write_that_upgrades_the_definition_restarts_the_op_log(
+ client: Any,
+ full_api_access: None,
+ canvas: Any,
+ published: MagicMock,
+ session: Session,
+) -> None:
+ patch_ops(client, 1, add("chart-1"))
+ # Stored by an older schema version.
+ canvas.definition_version = 0
+ session.commit()
+
+ assert patch_ops(client, 1, add("chart-2")).json["stale"] is True
+ assert patch_ops(client, 2, add("chart-2")).status_code == 200
+ assert canvas.definition_version == 1
+ assert client.get(f"{URL}/changes?since=1").status_code == 409
+ assert client.get(f"{URL}/changes?since=2").status_code == 200
+
+
def test_schema_is_published(
client: Any, full_api_access: None, widgets: FakeResolver
) -> None:
diff --git a/tests/unit_tests/canvas/ops_test.py b/tests/unit_tests/canvas/ops_test.py
index 85a0521609e..5449adb2733 100644
--- a/tests/unit_tests/canvas/ops_test.py
+++ b/tests/unit_tests/canvas/ops_test.py
@@ -114,6 +114,56 @@ def test_add_into_a_leaf_is_rejected() -> None:
build(add("chart-1"), add("chart-2", parent="n0"))
+def changed_rules(widget_type: str, **changes: Any) -> LayoutRulesRegistry:
+ """The test rules, with ``widget_type``'s rules changed after saving."""
+ rules = canvas_rules()
+ base = rules.get(widget_type)
+ rules.unregister(widget_type)
+ rules.register(type(f"Changed{base.__name__}", (base,), changes))
+ return rules
+
+
+def apply_with(
+ rules: LayoutRulesRegistry, doc: dict[str, Any], *raw: dict[str, Any]
+) -> dict[str, Any]:
+ return apply_operations(
+ doc, ops(*raw), rules=rules, resolver=FakeResolver(), new_id=sequential_ids()
+ )[0]
+
+
+def test_a_tightened_size_rule_only_applies_to_touched_nodes() -> None:
+ doc = build(add("chart-1", layout={"colSpan": 24}), add("chart-2"))
+ rules = changed_rules("chart", max_col_span=12)
+
+ result = apply_with(
+ rules, doc, {"op": "place", "id": "n1", "layout": {"colSpan": 6}}
+ )
+
+ assert result["nodes"]["n0"]["layout"]["colSpan"] == 24
+ with pytest.raises(DefinitionValidationError, match="above the maximum of 12"):
+ apply_with(rules, doc, {"op": "place", "id": "n0", "layout": {"colSpan": 24}})
+
+
+def test_a_tightened_nesting_rule_only_applies_to_touched_nodes() -> None:
+ doc = build(add("group"), add("chart-1", parent="n0"))
+ rules = changed_rules("group", accepted_children=frozenset({"filter"}))
+
+ result = apply_with(rules, doc, add("chart-2", id=GROUP_ID))
+
+ assert result["nodes"]["n0"]["children"] == ["n1"]
+ with pytest.raises(DefinitionValidationError, match="cannot be placed in group"):
+ apply_with(rules, doc, {"op": "move", "id": "n1", "parent": "n0", "index": 0})
+
+
+def test_a_layout_from_a_replaced_child_model_is_kept_as_stored() -> None:
+ doc = build(add("board"), add("chart-1", parent="n0", layout={"lane": "todo"}))
+ rules = changed_rules("board", child_layout_model=None, grid_columns=12)
+
+ result = apply_with(rules, doc, add("chart-2", id=GROUP_ID))
+
+ assert result["nodes"]["n1"]["layout"] == {"lane": "todo"}
+
+
def test_unresolved_container_keeps_its_children_but_takes_no_new_ones() -> None:
doc = build(add("group"), add("chart-1", parent="n0"), add("chart-2"))
# The group's widget type is no longer registered.
diff --git a/tests/unit_tests/canvas/upgrades_test.py b/tests/unit_tests/canvas/upgrades_test.py
new file mode 100644
index 00000000000..04d40d9c621
--- /dev/null
+++ b/tests/unit_tests/canvas/upgrades_test.py
@@ -0,0 +1,62 @@
+# 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
+
+import pytest
+
+from superset.canvas.definition.upgrades import (
+ DefinitionVersionError,
+ upgrade_definition,
+)
+
+
+def add_title(definition: dict[str, Any]) -> dict[str, Any]:
+ return {**definition, "title": "untitled"}
+
+
+def rename_title(definition: dict[str, Any]) -> dict[str, Any]:
+ definition["name"] = definition.pop("title")
+ return definition
+
+
+UPGRADES = {1: add_title, 2: rename_title}
+
+
+def test_current_definition_is_returned_as_is() -> None:
+ definition = {"version": 3}
+
+ assert upgrade_definition(definition, current=3, upgrades=UPGRADES) is definition
+
+
+def test_older_definition_runs_every_upgrade_in_order() -> None:
+ stored = {"version": 1, "nodes": {}}
+
+ upgraded = upgrade_definition(stored, current=3, upgrades=UPGRADES)
+
+ assert upgraded == {"version": 3, "nodes": {}, "name": "untitled"}
+ assert stored == {"version": 1, "nodes": {}}
+
+
+def test_newer_definition_is_refused() -> None:
+ with pytest.raises(DefinitionVersionError, match="Upgrade Superset"):
+ upgrade_definition({"version": 4}, current=3, upgrades=UPGRADES)
+
+
+@pytest.mark.parametrize("version", [0, "1", True, None])
+def test_malformed_version_is_refused(version: Any) -> None:
+ with pytest.raises(DefinitionVersionError, match="Unsupported"):
+ upgrade_definition({"version": version}, current=3, upgrades=UPGRADES)