diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index e6c7366df..67e84768a 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1947,6 +1947,12 @@ def __init__( self.enum = enum self.by_value = by_value + # If allow_none was not explicitly provided and the enum has a member + # whose value is None, None should be considered valid + # (mirrors fields.Constant(None) behavior). See issue #2985. + if kwargs.get("allow_none") is None and any(m.value is None for m in enum): + self.allow_none = True + # Serialization by name if by_value is False: self.field: Field = String() diff --git a/tests/test_fields.py b/tests/test_fields.py index 77d2233d5..331dc0123 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -1,3 +1,5 @@ +from enum import Enum + import pytest from marshmallow import ( @@ -753,3 +755,69 @@ def test_invalid_type_passed_to_post_load(self): match="The 'post_load' parameter must be a callable or an iterable of callables.", ): fields.Int(post_load="not_callable") # type: ignore[arg-type] + + +class NoneMemberEnum(Enum): + A = "a" + B = 2 + NONE = None + + +class NoNoneEnum(Enum): + A = "a" + B = "b" + + +class TestEnumAllowNone: + """`fields.Enum` should default ``allow_none=True`` when the passed Enum has a + member whose value is ``None`` (mirroring ``fields.Constant(None)``), unless the + user explicitly passes ``allow_none``. See issue #2985. + """ + + def test_by_name_defaults_allow_none_true_when_enum_has_none_member(self): + field = fields.Enum(NoneMemberEnum) + assert field.allow_none is True + assert field.deserialize(None) is None + assert field.serialize("x", {"x": None}) is None + + def test_by_value_defaults_allow_none_true_when_enum_has_none_member(self): + field = fields.Enum(NoneMemberEnum, by_value=True) + assert field.allow_none is True + assert field.deserialize(None) is None + assert field.serialize("x", {"x": None}) is None + + def test_by_value_field_defaults_allow_none_true_when_enum_has_none_member(self): + field = fields.Enum(NoneMemberEnum, by_value=fields.Raw) + assert field.allow_none is True + assert field.deserialize(None) is None + + def test_explicit_allow_none_false_still_errors_on_none_load(self): + field = fields.Enum(NoneMemberEnum, allow_none=False) + assert field.allow_none is False + with pytest.raises(ValidationError, match="Field may not be null."): + field.deserialize(None) + + def test_explicit_allow_none_false_by_value_still_errors_on_none_load(self): + field = fields.Enum(NoneMemberEnum, by_value=True, allow_none=False) + assert field.allow_none is False + with pytest.raises(ValidationError, match="Field may not be null."): + field.deserialize(None) + + def test_explicit_allow_none_false_errors_in_schema_load(self): + class MySchema(Schema): + sentinel = fields.Enum(NoneMemberEnum, by_value=True, allow_none=False) + + with pytest.raises(ValidationError) as excinfo: + MySchema().load({"sentinel": None}) + assert excinfo.value.messages == {"sentinel": ["Field may not be null."]} + + def test_enum_without_none_member_keeps_allow_none_false(self): + field = fields.Enum(NoNoneEnum) + assert field.allow_none is False + with pytest.raises(ValidationError, match="Field may not be null."): + field.deserialize(None) + + def test_explicit_allow_none_true_without_none_member(self): + field = fields.Enum(NoNoneEnum, allow_none=True) + assert field.allow_none is True + assert field.deserialize(None) is None