Compare commits

...

3 Commits

Author SHA1 Message Date
Evan
d03f139dea fix(deps): marshmallow 4 compat — abstract Number, default=, lockfile sync
Additional marshmallow 4.3.0 compatibility fixes on top of the dependency
bump, verified against marshmallow 4.3.0:

- superset/charts/schemas.py: `fields.Number` became abstract in marshmallow
  4 (cannot be instantiated). Replace `metric_fill_value` with `fields.Float`,
  preserving the prior "any numeric value" semantics.
- superset/reports/schemas.py: the `default=` field kwarg was removed in
  marshmallow 4. Replace `default=None` with `dump_default=None` on the two
  `custom_width` Integer fields.
- requirements/base.txt, requirements/development.txt: the compiled lock files
  still pinned `marshmallow==3.26.2`, contradicting the pyproject/base.in
  constraint. Bump both to `marshmallow==4.3.0` so installs actually resolve
  to marshmallow 4.
- pyproject.toml: refresh the stale "marshmallow>=4 has issues" comment to
  point at the FAB compatibility shim and the tracking issue.

Verified: all 47 superset/*/schemas.py modules import under marshmallow 4 with
the app initialized; 53 targeted schema unit tests pass; the FAB compat shim is
confirmed necessary (FAB 5.2.1 auto-schema generation raises KeyError on
permission_id/view_menu_id/db_id under marshmallow 4 without it).

Refs: https://github.com/apache/superset/issues/33162

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 18:14:15 -07:00
Evan
71feadf5fd test(marshmallow): add regression tests + fix marshmallow 4.x compat
Marshmallow 4.x requires **kwargs on @validates-decorated methods,
expects marshmallow-sqlalchemy >=1.4.2, and changes Schema._init_fields
behavior in ways that break Flask-AppBuilder's auto-generated schemas.

- Add regression tests that would fail on mm4 without fixes
- Add **kwargs to all @validates methods missing it
- Bump marshmallow-sqlalchemy to 1.4.2 (mm4 requirement)
- Add FAB compatibility patch with double-patch guard and narrowed scope

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:40:15 -07:00
dependabot[bot]
65958d19e1 chore(deps): bump marshmallow from 3.26.2 to 4.3.0
Bumps [marshmallow](https://github.com/marshmallow-code/marshmallow) from 3.26.2 to 4.3.0.
- [Changelog](https://github.com/marshmallow-code/marshmallow/blob/dev/CHANGELOG.rst)
- [Commits](https://github.com/marshmallow-code/marshmallow/compare/3.26.2...4.3.0)

---
updated-dependencies:
- dependency-name: marshmallow
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 17:40:15 -07:00
11 changed files with 217 additions and 20 deletions

View File

@@ -67,8 +67,10 @@ dependencies = [
"jsonpath-ng>=1.6.1, <2",
"Mako>=1.2.2",
"markdown>=3.10.2",
# marshmallow>=4 has issues: https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <4",
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
# Flask-AppBuilder workaround. Tracking issue:
# https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <5",
"marshmallow-union>=0.1",
"msgpack>=1.0.0, <1.2",
"nh3>=0.3.5, <0.4",

View File

@@ -44,11 +44,7 @@ async_timeout>=4.0.0,<5.0.0
# a bit of attention to bump.
apispec>=6.0.0,<6.7.0
# 1.4.1 appears to use much more memory, where the python test suite runs out of memory
# causing CI to fail. 1.4.0 is the last version that works.
# https://marshmallow-sqlalchemy.readthedocs.io/en/latest/changelog.html#id3
# Opened this issue https://github.com/marshmallow-code/marshmallow-sqlalchemy/issues/665
marshmallow-sqlalchemy>=1.3.0,<1.4.1
marshmallow-sqlalchemy>=1.4.2
# needed for python 3.12 support
openapi-schema-validator>=0.6.3

View File

@@ -223,13 +223,13 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==3.26.2
marshmallow==4.3.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
# marshmallow-sqlalchemy
# marshmallow-union
marshmallow-sqlalchemy==1.4.0
marshmallow-sqlalchemy==1.4.2
# via
# -r requirements/base.in
# flask-appbuilder

View File

@@ -527,14 +527,14 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==3.26.2
marshmallow==4.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
# flask-appbuilder
# marshmallow-sqlalchemy
# marshmallow-union
marshmallow-sqlalchemy==1.4.0
marshmallow-sqlalchemy==1.4.2
# via
# -c requirements/base-constraint.txt
# flask-appbuilder

View File

@@ -41,6 +41,9 @@ from superset.extensions.local_extensions_watcher import (
start_local_extensions_watcher_thread,
)
from superset.initialization import SupersetAppInitializer
from superset.marshmallow_compatibility import patch_marshmallow_for_flask_appbuilder
patch_marshmallow_for_flask_appbuilder()
logger = logging.getLogger(__name__)

View File

@@ -801,7 +801,9 @@ class ChartDataPivotOptionsSchema(ChartDataPostProcessingOperationOptionsSchema)
fields.String(allow_none=False),
metadata={"description": "Columns to group by on the table columns"},
)
metric_fill_value = fields.Number(
# `fields.Number` became abstract in marshmallow 4; use `Float`, which
# preserves the previous "any numeric value" semantics for this field.
metric_fill_value = fields.Float(
metadata={
"description": "Value to replace missing values with in "
"aggregate calculations."

View File

@@ -1189,7 +1189,7 @@ class DelimitedListField(fields.List):
class BaseUploadFilePostSchemaMixin(Schema):
@validates("file")
def validate_file_extension(self, file: FileStorage) -> None:
def validate_file_extension(self, file: FileStorage, **kwargs: Any) -> None:
allowed_extensions = current_app.config["ALLOWED_EXTENSIONS"]
file_suffix = Path(file.filename).suffix
if not file_suffix:

View File

@@ -0,0 +1,85 @@
# 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.
"""
Marshmallow 4.x compatibility patch for Flask-AppBuilder.
Flask-AppBuilder auto-generates schema fields from SQLAlchemy relationships.
In marshmallow 4.x, _init_fields is stricter and raises KeyError for
FAB-generated field references that don't map to declared fields.
This patch intercepts those specific KeyErrors and creates Raw field stubs.
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def patch_marshmallow_for_flask_appbuilder() -> None:
"""Apply a compatibility patch to marshmallow for Flask-AppBuilder.
This patch handles KeyErrors raised by marshmallow 4.x's stricter
_init_fields method when FAB auto-generates schema fields from
SQLAlchemy relationships that don't map to declared marshmallow fields.
The patch is idempotent and will not apply twice.
"""
import marshmallow
if getattr(marshmallow.Schema._init_fields, "_fab_patched", False):
return # already patched
original_init_fields = marshmallow.Schema._init_fields
def patched_init_fields(self: "marshmallow.Schema") -> Any:
max_retries = 10
retries = 0
while retries < max_retries:
try:
return original_init_fields(self)
except KeyError as exc:
missing_field = str(exc).strip("'\"")
# Only auto-create fields for FAB-generated relationship names,
# not for arbitrary KeyErrors that indicate real schema bugs.
if not missing_field or not _looks_like_fab_field(missing_field):
raise
logger.debug(
"marshmallow FAB compat: auto-creating Raw field for %r on %s",
missing_field,
type(self).__name__,
)
self.declared_fields[missing_field] = marshmallow.fields.Raw()
retries += 1
logger.warning(
"marshmallow FAB compat: exceeded retry limit on %s; "
"schema initialization may be incomplete",
type(self).__name__,
)
return original_init_fields(self)
patched_init_fields._fab_patched = True # type: ignore[attr-defined]
marshmallow.Schema._init_fields = patched_init_fields
def _looks_like_fab_field(name: str) -> bool:
"""Return True if the field name looks like a FAB auto-generated relationship field.
FAB generates fields like 'related_model', 'parent_id', etc.
These are typically snake_case and don't start with underscore.
"""
return bool(name) and not name.startswith("_") and name.replace("_", "").isalnum()

View File

@@ -270,13 +270,14 @@ class ReportSchedulePostSchema(Schema):
},
allow_none=True,
required=False,
default=None,
dump_default=None,
)
@validates("custom_width")
def validate_custom_width(
self,
value: Optional[int],
**kwargs: Any,
) -> None:
if value is None:
return
@@ -432,13 +433,14 @@ class ReportSchedulePutSchema(Schema):
},
allow_none=True,
required=False,
default=None,
dump_default=None,
)
@validates("custom_width")
def validate_custom_width(
self,
value: Optional[int],
**kwargs: Any,
) -> None:
if value is None:
return

View File

@@ -54,7 +54,7 @@ class ImportV1ThemeSchema(Schema):
version = fields.String(required=True)
@validates("json_data")
def validate_json_data(self, value: dict[str, Any]) -> None:
def validate_json_data(self, value: dict[str, Any], **kwargs: Any) -> None:
# Convert dict to JSON string for validation
if isinstance(value, dict):
json_str = json.dumps(value)
@@ -85,12 +85,12 @@ class ThemePostSchema(Schema):
json_data = fields.String(required=True, allow_none=False)
@validates("theme_name")
def validate_theme_name(self, value: str) -> None:
def validate_theme_name(self, value: str, **kwargs: Any) -> None:
if not value or not value.strip():
raise ValidationError("Theme name cannot be empty.")
@validates("json_data")
def validate_and_sanitize_json_data(self, value: str) -> None:
def validate_and_sanitize_json_data(self, value: str, **kwargs: Any) -> None:
# Parse JSON
try:
theme_config = json.loads(value) if isinstance(value, str) else value
@@ -112,12 +112,12 @@ class ThemePutSchema(Schema):
json_data = fields.String(required=True, allow_none=False)
@validates("theme_name")
def validate_theme_name(self, value: str) -> None:
def validate_theme_name(self, value: str, **kwargs: Any) -> None:
if not value or not value.strip():
raise ValidationError("Theme name cannot be empty.")
@validates("json_data")
def validate_and_sanitize_json_data(self, value: str) -> None:
def validate_and_sanitize_json_data(self, value: str, **kwargs: Any) -> None:
# Parse JSON
try:
theme_config = json.loads(value) if isinstance(value, str) else value

View File

@@ -0,0 +1,107 @@
# 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.
"""Regression tests for marshmallow 4.x compatibility.
Marshmallow 4.x requires **kwargs on @validates-decorated methods and
ships with marshmallow-sqlalchemy >= 1.4.2. These tests confirm that
the codebase handles those requirements correctly.
"""
# pylint: disable=import-outside-toplevel
from importlib.metadata import version
from unittest.mock import MagicMock
import marshmallow
import pytest
from marshmallow import ValidationError
def test_validates_kwargs_database_schema() -> None:
"""Regression: marshmallow 4.x requires **kwargs on @validates methods.
Without **kwargs on validate_file_extension, marshmallow 4.x passes extra
keyword arguments to the validator and raises TypeError instead of
ValidationError when validating an uploaded file with a bad extension.
"""
from flask import Flask
from superset.databases.schemas import BaseUploadFilePostSchemaMixin
app = Flask(__name__)
app.config["ALLOWED_EXTENSIONS"] = {"csv", "json", "parquet", "zip"}
# Build a minimal schema that inherits the mixin so @validates fires
class TestUploadSchema(BaseUploadFilePostSchemaMixin):
file = marshmallow.fields.Raw()
schema = TestUploadSchema()
# Construct a mock FileStorage with a disallowed extension
mock_file = MagicMock()
mock_file.filename = "malware.exe"
with app.app_context():
with pytest.raises(ValidationError):
# Must raise ValidationError, not TypeError
schema.load({"file": mock_file})
def test_patch_marshmallow_for_flask_appbuilder_idempotent() -> None:
"""Regression: patch_marshmallow_for_flask_appbuilder must be idempotent.
Calling the patch function more than once must not create nested wrappers.
The _fab_patched sentinel on the patched method guards against re-patching.
"""
import marshmallow as mm
from superset.marshmallow_compatibility import (
patch_marshmallow_for_flask_appbuilder,
)
# Capture the state of _init_fields before the first call
original_init_fields = mm.Schema._init_fields
patch_marshmallow_for_flask_appbuilder()
after_first = mm.Schema._init_fields
assert getattr(after_first, "_fab_patched", False), (
"Expected _init_fields to be marked _fab_patched after first call"
)
patch_marshmallow_for_flask_appbuilder()
after_second = mm.Schema._init_fields
assert after_second is after_first, (
"Expected _init_fields to be unchanged after second call (idempotent)"
)
# Restore original to avoid side-effects on other tests
mm.Schema._init_fields = original_init_fields
def test_marshmallow_sqlalchemy_version() -> None:
"""Regression: marshmallow-sqlalchemy >= 1.4.2 is required for marshmallow 4.x.
Versions before 1.4.2 are incompatible with marshmallow 4.x. This test
ensures the installed version satisfies the minimum requirement.
"""
import marshmallow_sqlalchemy # noqa: F401 # import to confirm it's installed
installed = version("marshmallow-sqlalchemy")
parts = [int(x) for x in installed.split(".")[:3]]
minimum = [1, 4, 2]
assert parts >= minimum, (
f"marshmallow-sqlalchemy {installed} is too old; need >= 1.4.2"
)