Files
superset2/tests/unit_tests/test_marshmallow4_compat.py
dependabot[bot] 919bd35028 chore(deps): bump marshmallow from 3.26.2 to 4.3.0 (#39751)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-06-19 10:02:35 -07:00

165 lines
6.2 KiB
Python

# 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.5.0. 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
@pytest.mark.skipif(
tuple(int(p) for p in version("marshmallow").split(".")[:1]) < (4,),
reason="FAB _init_fields KeyError only occurs on marshmallow >= 4",
)
def test_fab_field_stubbing_is_single_pass() -> None:
"""Regression: FAB compat must stub missing fields in one pass per instance.
Flask-AppBuilder mints a fresh schema class on every call and marshmallow
rebuilds ``declared_fields`` for every instance, so any approach that re-runs
``_init_fields`` on KeyError (a retry loop) re-pays its cost on every single
instantiation. Under coverage tracing that blew the unit-test job past its
timeout. The patch must instead stub the undeclared FAB names before the
original runs, so ``_init_fields`` executes exactly once per instance.
"""
import marshmallow as mm
from superset.marshmallow_compatibility import (
patch_marshmallow_for_flask_appbuilder,
)
# Count invocations of the underlying (pre-patch) _init_fields, then patch.
base_init_fields = mm.Schema._init_fields
calls = {"n": 0}
def counting(self: mm.Schema) -> object:
calls["n"] += 1
return base_init_fields(self)
mm.Schema._init_fields = counting
try:
patch_marshmallow_for_flask_appbuilder()
# FAB-style schema: Meta.fields references undeclared relationship names,
# which marshmallow 4.x rejects with KeyError.
class FabStyleSchema(mm.Schema):
a = mm.fields.String()
class Meta:
fields = ("a", "related_model", "owner", "parent_id")
calls["n"] = 0
for _ in range(50):
FabStyleSchema()
# Exactly one underlying _init_fields per instance — no retry blowup.
assert calls["n"] == 50, (
f"expected 50 _init_fields calls for 50 instances, got {calls['n']} "
"— single-pass stubbing regressed into a retry loop"
)
# The undeclared FAB names resolve to Raw stubs in the built field set.
instance = FabStyleSchema()
assert isinstance(instance.fields["related_model"], mm.fields.Raw)
finally:
mm.Schema._init_fields = base_init_fields
def test_marshmallow_sqlalchemy_version() -> None:
"""Regression: marshmallow-sqlalchemy >= 1.5.0 is required.
marshmallow 4.x needs marshmallow-sqlalchemy >= 1.4.x, but 1.4.1/1.4.2 carry
a memory regression (issue #665) that exhausts memory in the test suite; only
1.5.0 fixes it. This test ensures the installed version is recent enough.
"""
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, 5, 0]
assert parts >= minimum, (
f"marshmallow-sqlalchemy {installed} is too old; need >= 1.5.0"
)