mirror of
https://github.com/apache/superset.git
synced 2026-06-12 11:09:15 +00:00
Compare commits
3 Commits
fix/chart-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d03f139dea | ||
|
|
71feadf5fd | ||
|
|
65958d19e1 |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
85
superset/marshmallow_compatibility.py
Normal file
85
superset/marshmallow_compatibility.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
107
tests/unit_tests/test_marshmallow4_compat.py
Normal file
107
tests/unit_tests/test_marshmallow4_compat.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user