Repository: marshmallow-code/marshmallow
Pull request #2017 (2022-07-20 14:11:08)

Add Enum field
Replaces @orenc17's #1885.

This is a long standing feature request. See for instance discussions in #267.

The status quo is that people can use @justanr's [marshmallow_enum](https://github.com/justanr/marshmallow_enum).

Currently, this lib doesn't seem actively maintained. I don't blame the author. A few things are outdated, deprecated stuff. The changes since MA3 have been user contributed and some are still missing.

I'm thinking if we recommend this lib as the reference choice, why not integrate it in code? This is what I've been trying to do.

I started by importing the code and updating it for PY3/MA3.

I also removed the dump/load asymmetry (dump by value, load by name) that I found awkward and revamped the error message generation.

I was still not happy with the `by_value`/`by_name` mechanism. I serialize by name, so I could live with only this, but I understand the need for serializing by value (to get an int or a nicer string). However, I like the type to be well-defined so that we can inherit deserialization from basic fields, and to help documenting the type (e.g. in apispec).

My proposal is therefore an `Enum` field serializing by name, and typed enum fields `StringEnum` and `IntegerEnum`, to serialize by value with a given type. Users would be free to create new types but I believe those cover most cases.

Transition from `marshmallow_enum` should be relatively smooth for users who don't use the dump/load asymmetry and who don't rely on exact error messages.

This still lacks a bit of doc but I'd rather get feedback before polishing.

Note we can't use `OneOf` validator because validation occurs after deserialization and wrong values are caught at deserialization already.



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 84a5cd45..7de43da5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,15 +1,6 @@
 Changelog
 ---------
 
-3.17.1 (2022-08-22)
-*******************
-
-Bug fixes:
-
-- Add return type to `fields.Email.__init__` (:pr:`2018`).
-  Thanks :user:`kkirsche` for the PR.
-- Add missing type hint to IPInterface __init__ (:pr:`2036`).
-
 3.17.0 (2022-06-26)
 *******************
 
diff --git a/setup.py b/setup.py
index 739ce945..f997aac3 100644
--- a/setup.py
+++ b/setup.py
@@ -5,16 +5,16 @@ EXTRAS_REQUIRE = {
     "tests": ["pytest", "pytz", "simplejson"],
     "lint": [
         "mypy==0.971",
-        "flake8==5.0.4",
-        "flake8-bugbear==22.8.23",
+        "flake8==4.0.1",
+        "flake8-bugbear==22.7.1",
         "pre-commit~=2.4",
     ],
     "docs": [
-        "sphinx==5.1.1",
+        "sphinx==4.5.0",
         "sphinx-issues==3.0.1",
         "alabaster==0.7.12",
         "sphinx-version-warning==1.1.2",
-        "autodocsumm==0.2.9",
+        "autodocsumm==0.2.8",
     ],
 }
 EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
diff --git a/src/marshmallow/__init__.py b/src/marshmallow/__init__.py
index bcc8f7a2..ff10f277 100644
--- a/src/marshmallow/__init__.py
+++ b/src/marshmallow/__init__.py
@@ -16,7 +16,7 @@ from marshmallow.utils import EXCLUDE, INCLUDE, RAISE, missing, pprint
 
 from . import fields
 
-__version__ = "3.17.1"
+__version__ = "3.17.0"
 __parsed_version__ = Version(__version__)
 __version_info__: tuple[int, int, int] | tuple[
     int, int, int, str, int
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 8c6bb41b..45a5572e 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -11,6 +11,7 @@ import decimal
 import math
 import typing
 import warnings
+from enum import Enum
 from collections.abc import Mapping as _Mapping
 
 from marshmallow import validate, utils, class_registry, types
@@ -59,6 +60,8 @@ __all__ = [
     "IPInterface",
     "IPv4Interface",
     "IPv6Interface",
+    "EnumSymbol",
+    "EnumValue",
     "Method",
     "Function",
     "Str",
@@ -1731,7 +1734,7 @@ class Email(String):
     #: Default error messages.
     default_error_messages = {"invalid": "Not a valid email address."}
 
-    def __init__(self, *args, **kwargs) -> None:
+    def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         # Insert validation into self.validators so that multiple errors can be stored.
         validator = validate.Email(error=self.error_messages["invalid"])
@@ -1815,7 +1818,7 @@ class IPInterface(Field):
 
     DESERIALIZATION_CLASS = None  # type: typing.Optional[typing.Type]
 
-    def __init__(self, *args, exploded: bool = False, **kwargs):
+    def __init__(self, *args, exploded=False, **kwargs):
         super().__init__(*args, **kwargs)
         self.exploded = exploded
 
@@ -1855,6 +1858,78 @@ class IPv6Interface(IPInterface):
     DESERIALIZATION_CLASS = ipaddress.IPv6Interface
 
 
+class EnumSymbol(String):
+    """An Enum field (de)serializing enum members by symbol (name) as string.
+
+    :param enum Enum: Enum class
+
+    .. versionadded:: 3.18.0
+    """
+
+    default_error_messages = {
+        "unknown": "Must be one of: {choices}.",
+    }
+
+    def __init__(self, enum: type[Enum], **kwargs):
+        self.enum = enum
+        self.choices = ", ".join(enum.__members__)
+        super().__init__(**kwargs)
+
+    def _serialize(self, value, attr, obj, **kwargs):
+        if value is None:
+            return None
+        return value.name
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        value = super()._deserialize(value, attr, data, **kwargs)
+        try:
+            return getattr(self.enum, value)
+        except AttributeError as exc:
+            raise self.make_error("unknown", choices=self.choices) from exc
+
+
+class EnumValue(Field):
+    """An Enum field (de)serializing enum members by value.
+
+    A Field must be provided to (de)serialize the value.
+
+    :param cls_or_instance: Field class or instance.
+    :param enum Enum: Enum class
+
+    .. versionadded:: 3.18.0
+    """
+
+    default_error_messages = {
+        "unknown": "Must be one of: {choices}.",
+    }
+
+    def __init__(self, cls_or_instance: Field | type, enum: type[Enum], **kwargs):
+        super().__init__(**kwargs)
+        try:
+            self.field = resolve_field_instance(cls_or_instance)
+        except FieldInstanceResolutionError as error:
+            raise ValueError(
+                "The enum field must be a subclass or instance of "
+                "marshmallow.base.FieldABC."
+            ) from error
+        self.enum = enum
+        self.choices = ", ".join(
+            [str(self.field._serialize(m.value, None, None)) for m in enum]
+        )
+
+    def _serialize(self, value, attr, obj, **kwargs):
+        if value is None:
+            return None
+        return self.field._serialize(value.value, attr, obj, **kwargs)
+
+    def _deserialize(self, value, attr, data, **kwargs):
+        value = self.field._deserialize(value, attr, data, **kwargs)
+        try:
+            return self.enum(value)
+        except ValueError as exc:
+            raise self.make_error("unknown", choices=self.choices) from exc
+
+
 class Method(Field):
     """A field that takes the value returned by a `Schema` method.
 


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 597d3f94..4dbbcd5f 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,6 +1,8 @@
 """Test utilities and fixtures."""
+import functools
 import datetime as dt
 import uuid
+from enum import Enum, IntEnum
 
 import simplejson
 
@@ -12,6 +14,25 @@ from marshmallow.exceptions import ValidationError
 central = pytz.timezone("US/Central")
 
 
+class GenderEnum(IntEnum):
+    male = 1
+    female = 2
+    non_binary = 3
+
+
+class HairColorEnum(Enum):
+    black = "black hair"
+    brown = "brown hair"
+    blond = "blond hair"
+    red = "red hair"
+
+
+class DateEnum(Enum):
+    date_1 = dt.date(2004, 2, 29)
+    date_2 = dt.date(2008, 2, 29)
+    date_3 = dt.date(2012, 2, 29)
+
+
 ALL_FIELDS = [
     fields.String,
     fields.Integer,
@@ -33,8 +54,12 @@ ALL_FIELDS = [
     fields.IPInterface,
     fields.IPv4Interface,
     fields.IPv6Interface,
+    functools.partial(fields.EnumSymbol, GenderEnum),
+    functools.partial(fields.EnumValue, fields.String, HairColorEnum),
+    functools.partial(fields.EnumValue, fields.Integer, GenderEnum),
 ]
 
+
 ##### Custom asserts #####
 
 
@@ -69,7 +94,8 @@ class User:
         birthdate=None,
         birthtime=None,
         balance=100,
-        sex="male",
+        sex=GenderEnum.male,
+        hair_color=HairColorEnum.black,
         employer=None,
         various_data=None,
     ):
@@ -86,8 +112,8 @@ class User:
         self.email = email
         self.balance = balance
         self.registered = registered
-        self.hair_colors = ["black", "brown", "blond", "redhead"]
-        self.sex_choices = ("male", "female")
+        self.hair_colors = list(HairColorEnum.__members__)
+        self.sex_choices = list(GenderEnum.__members__)
         self.finger_count = 10
         self.uid = uuid.uuid1()
         self.time_registered = time_registered or dt.time(1, 23, 45, 6789)
@@ -95,6 +121,7 @@ class User:
         self.birthtime = birthtime or dt.time(0, 1, 2, 3333)
         self.activation_date = dt.date(2013, 12, 11)
         self.sex = sex
+        self.hair_color = hair_color
         self.employer = employer
         self.relatives = []
         self.various_data = various_data or {
@@ -180,7 +207,7 @@ class UserSchema(Schema):
     birthtime = fields.Time()
     activation_date = fields.Date()
     since_created = fields.TimeDelta()
-    sex = fields.Str(validate=validate.OneOf(["male", "female"]))
+    sex = fields.Str(validate=validate.OneOf(list(GenderEnum.__members__)))
     various_data = fields.Dict()
 
     class Meta:
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 4ee5ba5b..b922d6a5 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -10,7 +10,15 @@ from marshmallow import EXCLUDE, INCLUDE, RAISE, fields, Schema, validate
 from marshmallow.exceptions import ValidationError
 from marshmallow.validate import Equal
 
-from tests.base import assert_date_equal, assert_time_equal, central, ALL_FIELDS
+from tests.base import (
+    assert_date_equal,
+    assert_time_equal,
+    central,
+    ALL_FIELDS,
+    GenderEnum,
+    HairColorEnum,
+    DateEnum,
+)
 
 
 class TestDeserializingNone:
@@ -1089,6 +1097,57 @@ class TestFieldDeserialization:
 
         assert excinfo.value.args[0] == "Not a valid IPv6 interface."
 
+    def test_enumsymbol_field_deserialization(self):
+        field = fields.EnumSymbol(GenderEnum)
+        assert field.deserialize("male") == GenderEnum.male
+
+    def test_enumsymbol_field_invalid_value(self):
+        field = fields.EnumSymbol(GenderEnum)
+        with pytest.raises(
+            ValidationError, match="Must be one of: male, female, non_binary."
+        ):
+            field.deserialize("dummy")
+
+    def test_enumsymbol_field_not_string(self):
+        field = fields.EnumSymbol(GenderEnum)
+        with pytest.raises(ValidationError, match="Not a valid string."):
+            field.deserialize(12)
+
+    def test_enumvalue_field_deserialization(self):
+        field = fields.EnumValue(fields.String, HairColorEnum)
+        assert field.deserialize("black hair") == HairColorEnum.black
+        field = fields.EnumValue(fields.Integer, GenderEnum)
+        assert field.deserialize(1) == GenderEnum.male
+        field = fields.EnumValue(fields.Date(format="%d/%m/%Y"), DateEnum)
+        assert field.deserialize("29/02/2004") == DateEnum.date_1
+
+    def test_enumvalue_field_invalid_value(self):
+        field = fields.EnumValue(fields.String, HairColorEnum)
+        with pytest.raises(
+            ValidationError,
+            match="Must be one of: black hair, brown hair, blond hair, red hair.",
+        ):
+            field.deserialize("dummy")
+        field = fields.EnumValue(fields.Integer, GenderEnum)
+        with pytest.raises(ValidationError, match="Must be one of: 1, 2, 3."):
+            field.deserialize(12)
+        field = fields.EnumValue(fields.Date(format="%d/%m/%Y"), DateEnum)
+        with pytest.raises(
+            ValidationError, match="Must be one of: 29/02/2004, 29/02/2008, 29/02/2012."
+        ):
+            field.deserialize("28/02/2004")
+
+    def test_enumvalue_field_wrong_type(self):
+        field = fields.EnumValue(fields.String, HairColorEnum)
+        with pytest.raises(ValidationError, match="Not a valid string."):
+            field.deserialize(12)
+        field = fields.EnumValue(fields.Integer, GenderEnum)
+        with pytest.raises(ValidationError, match="Not a valid integer."):
+            field.deserialize("dummy")
+        field = fields.EnumValue(fields.Date(format="%d/%m/%Y"), DateEnum)
+        with pytest.raises(ValidationError, match="Not a valid date."):
+            field.deserialize("30/02/2004")
+
     def test_deserialization_function_must_be_callable(self):
         with pytest.raises(TypeError):
             fields.Function(lambda x: None, deserialize="notvalid")
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 41eed709..8e751e5c 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -11,7 +11,7 @@ import pytest
 
 from marshmallow import Schema, fields, missing as missing_
 
-from tests.base import User, ALL_FIELDS, central
+from tests.base import User, ALL_FIELDS, central, GenderEnum, HairColorEnum, DateEnum
 
 
 class DateTimeList:
@@ -255,6 +255,22 @@ class TestFieldSerialization:
             == ipv6interface_exploded_string
         )
 
+    def test_enumsymbol_field_serialization(self, user):
+        user.sex = GenderEnum.male
+        field = fields.EnumSymbol(GenderEnum)
+        assert field.serialize("sex", user) == "male"
+
+    def test_enumvalue_field_serialization(self, user):
+        user.hair_color = HairColorEnum.black
+        field = fields.EnumValue(fields.String, HairColorEnum)
+        assert field.serialize("hair_color", user) == "black hair"
+        user.sex = GenderEnum.male
+        field = fields.EnumValue(fields.Integer, GenderEnum)
+        assert field.serialize("sex", user) == 1
+        user.some_date = DateEnum.date_1
+        field = fields.EnumValue(fields.Date(format="%d/%m/%Y"), DateEnum)
+        assert field.serialize("some_date", user) == "29/02/2004"
+
     def test_decimal_field(self, user):
         user.m1 = 12
         user.m2 = "12.355"