From c46f08398b40b2b08fbf7dab7fc29fbb259c4513 Mon Sep 17 00:00:00 2001 From: Renan Rodrigues dos Santos Date: Fri, 12 Dec 2025 10:30:23 -0300 Subject: [PATCH 1/2] Add support for `Enum` --- src/serialite/_dispatcher.py | 8 ++ src/serialite/_implementations/__init__.py | 1 + src/serialite/_implementations/_enum.py | 58 ++++++++ tests/implementations/test_enum.py | 148 +++++++++++++++++++++ tests/test_dispatcher.py | 31 +++++ 5 files changed, 246 insertions(+) create mode 100644 src/serialite/_implementations/_enum.py create mode 100644 tests/implementations/test_enum.py diff --git a/src/serialite/_dispatcher.py b/src/serialite/_dispatcher.py index 5fa9027..aa740ea 100644 --- a/src/serialite/_dispatcher.py +++ b/src/serialite/_dispatcher.py @@ -2,6 +2,7 @@ from abc import get_cache_token from datetime import datetime +from enum import Enum from pathlib import Path from types import GenericAlias, UnionType from typing import Any, Literal, NewType, TypeAliasType, Union, get_origin @@ -245,6 +246,13 @@ def path_serializer(cls): return PathSerializer() +@serializer.register(Enum) +def enum_serializer(cls): + from ._implementations._enum import EnumSerializer + + return EnumSerializer(cls) + + # Union disables subclassing so Optional cannot be used to dispatch # @serializer.register(Optional) def optional_serializer(cls): diff --git a/src/serialite/_implementations/__init__.py b/src/serialite/_implementations/__init__.py index b7796f3..20bf3b9 100644 --- a/src/serialite/_implementations/__init__.py +++ b/src/serialite/_implementations/__init__.py @@ -5,6 +5,7 @@ OrderedDictSerializer, RawDictSerializer, ) +from ._enum import EnumSerializer, InvalidEnumValueError from ._float import FloatSerializer from ._integer import ( IntegerOutOfRangeError, diff --git a/src/serialite/_implementations/_enum.py b/src/serialite/_implementations/_enum.py new file mode 100644 index 0000000..e4e93a7 --- /dev/null +++ b/src/serialite/_implementations/_enum.py @@ -0,0 +1,58 @@ +__all__ = ["EnumSerializer", "InvalidEnumValueError"] + +from dataclasses import dataclass +from enum import Enum, IntEnum, StrEnum +from typing import Any + +from .._base import Serializer +from .._decorators import serializable +from .._errors import Errors +from .._numeric_check import is_int +from .._result import Failure, Result, Success +from .._type_errors import ExpectedIntegerError, ExpectedStringError + + +class EnumSerializer[E: Enum](Serializer[E]): + def __init__(self, enum_class: type[E]): + self.enum_class = enum_class + + def from_data(self, data) -> Result[E]: + if issubclass(self.enum_class, StrEnum): + if not isinstance(data, str): + return Failure(Errors.one(ExpectedStringError(data))) + elif issubclass(self.enum_class, IntEnum): + if not is_int(data): + return Failure(Errors.one(ExpectedIntegerError(data))) + + try: + return Success(self.enum_class(data)) + except ValueError: + values = [m.value for m in self.enum_class] + err = InvalidEnumValueError(self.enum_class.__name__, values, data) + return Failure(Errors.one(err)) + + def to_data(self, value: E): + if not isinstance(value, self.enum_class): + raise ValueError(f"Expected {self.enum_class.__name__}, got {type(value).__name__}") + return value.value + + def to_openapi_schema(self, force: bool = False): + values = [m.value for m in self.enum_class] + + if all(isinstance(v, str) for v in values): + return {"type": "string", "enum": values} + elif all(is_int(v) for v in values): + return {"type": "integer", "enum": values} + else: + return {"enum": values} + + +@serializable +@dataclass(frozen=True, slots=True) +class InvalidEnumValueError(Exception): + enum_name: str + values: list[Any] + actual: Any + + def __str__(self) -> str: + return f"Expected one of {self.values!r} for {self.enum_name}, but got {self.actual!r}" diff --git a/tests/implementations/test_enum.py b/tests/implementations/test_enum.py new file mode 100644 index 0000000..1e60f7c --- /dev/null +++ b/tests/implementations/test_enum.py @@ -0,0 +1,148 @@ +from datetime import datetime +from enum import Enum, IntEnum, StrEnum, auto + +import pytest + +from serialite import ( + EnumSerializer, + Errors, + ExpectedIntegerError, + ExpectedStringError, + Failure, + InvalidEnumValueError, + Success, +) + +DATE = datetime(2024, 1, 1, 12, 0, 0) + + +def serializer_from_enum(enum) -> EnumSerializer: + return EnumSerializer(enum) + + +def serializer_from_enum_member(enum_member) -> EnumSerializer: + return serializer_from_enum(type(enum_member)) + + +class Color(Enum): + RED = "red" + GREEN = "green" + + +class Priority(IntEnum): + LOW = 1 + HIGH = auto() + + +class Status(StrEnum): + ACTIVE = "active" + INACTIVE = auto() + + +class CaseInsensitiveStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + for member in cls: + if member.value.lower() == value.lower(): + return member + return None + + +class MixedEnum(Enum): + STRING = "hello" + NUMBER = 42 + DATE = DATE + + +@pytest.mark.parametrize( + ("enum_member", "value"), + [ + (Color.RED, "red"), + (Color.GREEN, "green"), + (Priority.LOW, 1), + (Priority.HIGH, 2), + (Status.ACTIVE, "active"), + (Status.INACTIVE, "inactive"), + (MixedEnum.STRING, "hello"), + (MixedEnum.NUMBER, 42), + (MixedEnum.DATE, DATE), + ], +) +def test_valid_inputs(enum_member, value): + serializer = serializer_from_enum_member(enum_member) + assert serializer.from_data(value) == Success(enum_member) + assert serializer.to_data(enum_member) == value + + +@pytest.mark.parametrize( + ("enum", "data", "expected"), + [ + (Priority, "1", ExpectedIntegerError("1")), + (Priority, True, ExpectedIntegerError(True)), + (Status, 123, ExpectedStringError(123)), + ], +) +def test_wrong_type(enum, data, expected): + serializer = serializer_from_enum(enum) + assert serializer.from_data(data) == Failure(Errors.one(expected)) + + +@pytest.mark.parametrize( + ("data", "enum"), + [("invalid", Color), (99, Priority), ("invalid", Status), ("invalid", CaseInsensitiveStatus)], +) +def test_invalid_value(data, enum): + serializer = serializer_from_enum(enum) + values = [m.value for m in enum] + enum_name = enum.__name__ + + expected = Failure(Errors.one(InvalidEnumValueError(enum_name, values, data))) + assert serializer.from_data(data) == expected + + +def test_to_data_invalid(): + serializer = serializer_from_enum(Color) + + with pytest.raises(ValueError): + serializer.to_data("invalid") + + +@pytest.mark.parametrize( + ("data", "expected"), + [ + ("AcTiVe", CaseInsensitiveStatus.ACTIVE), + ("active", CaseInsensitiveStatus.ACTIVE), + ("INACTIVE", CaseInsensitiveStatus.INACTIVE), + ], +) +def test_missing_method(data, expected): + serializer = serializer_from_enum(CaseInsensitiveStatus) + assert serializer.from_data(data) == Success(expected) + + +def test_invalid_enum_value_error_string(): + e = InvalidEnumValueError("Color", ["red", "green"], "invalid") + assert e.to_data() == { + "enum_name": "Color", + "values": ["red", "green"], + "actual": "invalid", + } + assert str(e) == "Expected one of ['red', 'green'] for Color, but got 'invalid'" + + +@pytest.mark.parametrize( + ("enum", "expected"), + [ + (Color, {"type": "string", "enum": ["red", "green"]}), + (Priority, {"type": "integer", "enum": [1, 2]}), + (Status, {"type": "string", "enum": ["active", "inactive"]}), + (MixedEnum, {"enum": ["hello", 42, DATE]}), + ], +) +def test_to_openapi_schema(enum, expected): + serializer = serializer_from_enum(enum) + assert serializer.to_openapi_schema() == expected diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 62dfc2f..8bfa3d2 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -1,4 +1,5 @@ from datetime import datetime +from enum import Enum, IntEnum, StrEnum from typing import Any, Dict, List, Literal, NewType, Optional, Tuple, Union from uuid import UUID @@ -134,3 +135,33 @@ def test_dispatch_dict_with_nested_type_alias_str_key(): dict_serializer = serializer(dict[NestedKey, int]) assert dict_serializer.from_data({"a": 1, "b": 2}) == Success({"a": 1, "b": 2}) assert dict_serializer.to_data({"a": 1, "b": 2}) == {"a": 1, "b": 2} + + +def test_dispatch_enum(): + class Color(Enum): + RED = "red" + GREEN = "green" + + color_serializer = serializer(Color) + assert color_serializer.from_data("red") == Success(Color.RED) + assert color_serializer.to_data(Color.GREEN) == "green" + + +def test_dispatch_int_enum(): + class Priority(IntEnum): + LOW = 1 + HIGH = 2 + + priority_serializer = serializer(Priority) + assert priority_serializer.from_data(1) == Success(Priority.LOW) + assert priority_serializer.to_data(Priority.HIGH) == 2 + + +def test_dispatch_str_enum(): + class Status(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + status_serializer = serializer(Status) + assert status_serializer.from_data("active") == Success(Status.ACTIVE) + assert status_serializer.to_data(Status.INACTIVE) == "inactive" From 3abbb424152de26275a034e8b697a850fb530500 Mon Sep 17 00:00:00 2001 From: Renan Rodrigues dos Santos Date: Sun, 15 Feb 2026 09:17:15 -0300 Subject: [PATCH 2/2] Serialize Enums by name by default --- src/serialite/_dispatcher.py | 18 ++- src/serialite/_implementations/_enum.py | 47 +++++-- tests/implementations/test_enum.py | 174 +++++++++++++----------- tests/test_dispatcher.py | 48 +++---- 4 files changed, 170 insertions(+), 117 deletions(-) diff --git a/src/serialite/_dispatcher.py b/src/serialite/_dispatcher.py index aa740ea..06a39c7 100644 --- a/src/serialite/_dispatcher.py +++ b/src/serialite/_dispatcher.py @@ -2,7 +2,7 @@ from abc import get_cache_token from datetime import datetime -from enum import Enum +from enum import Enum, IntEnum, StrEnum from pathlib import Path from types import GenericAlias, UnionType from typing import Any, Literal, NewType, TypeAliasType, Union, get_origin @@ -253,6 +253,22 @@ def enum_serializer(cls): return EnumSerializer(cls) +# int appears before Enum in IntEnum's MRO, so it would dispatch to int without this +@serializer.register(IntEnum) +def int_enum_serializer(cls): + from ._implementations._enum import EnumSerializer + + return EnumSerializer(cls) + + +# str appears before Enum in StrEnum's MRO, so it would dispatch to str without this +@serializer.register(StrEnum) +def str_enum_serializer(cls): + from ._implementations._enum import EnumSerializer + + return EnumSerializer(cls) + + # Union disables subclassing so Optional cannot be used to dispatch # @serializer.register(Optional) def optional_serializer(cls): diff --git a/src/serialite/_implementations/_enum.py b/src/serialite/_implementations/_enum.py index e4e93a7..e929011 100644 --- a/src/serialite/_implementations/_enum.py +++ b/src/serialite/_implementations/_enum.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from enum import Enum, IntEnum, StrEnum -from typing import Any +from typing import Any, Literal from .._base import Serializer from .._decorators import serializable @@ -13,10 +13,31 @@ class EnumSerializer[E: Enum](Serializer[E]): - def __init__(self, enum_class: type[E]): + def __init__(self, enum_class: type[E], *, by: Literal["name", "value"] = "name"): + if by not in ["name", "value"]: + raise ValueError(f"Expected 'name' or 'value' for by, but got {by!r}") + self.enum_class = enum_class + self.by = by def from_data(self, data) -> Result[E]: + if self.by == "name": + return self._from_data_by_name(data) + else: + return self._from_data_by_value(data) + + def _from_data_by_name(self, data) -> Result[E]: + if not isinstance(data, str): + return Failure(Errors.one(ExpectedStringError(data))) + + try: + return Success(self.enum_class[data]) + except KeyError: + names = [m.name for m in self.enum_class] + err = InvalidEnumValueError(self.enum_class.__name__, names, data) + return Failure(Errors.one(err)) + + def _from_data_by_value(self, data) -> Result[E]: if issubclass(self.enum_class, StrEnum): if not isinstance(data, str): return Failure(Errors.one(ExpectedStringError(data))) @@ -34,17 +55,23 @@ def from_data(self, data) -> Result[E]: def to_data(self, value: E): if not isinstance(value, self.enum_class): raise ValueError(f"Expected {self.enum_class.__name__}, got {type(value).__name__}") - return value.value - def to_openapi_schema(self, force: bool = False): - values = [m.value for m in self.enum_class] + if self.by == "name": + return value.name + else: + return value.value - if all(isinstance(v, str) for v in values): - return {"type": "string", "enum": values} - elif all(is_int(v) for v in values): - return {"type": "integer", "enum": values} + def to_openapi_schema(self, force: bool = False): + if self.by == "name": + return {"type": "string", "enum": [m.name for m in self.enum_class]} else: - return {"enum": values} + values = [m.value for m in self.enum_class] + if all(isinstance(v, str) for v in values): + return {"type": "string", "enum": values} + elif all(is_int(v) for v in values): + return {"type": "integer", "enum": values} + else: + return {"enum": values} @serializable diff --git a/tests/implementations/test_enum.py b/tests/implementations/test_enum.py index 1e60f7c..24a298c 100644 --- a/tests/implementations/test_enum.py +++ b/tests/implementations/test_enum.py @@ -13,16 +13,6 @@ Success, ) -DATE = datetime(2024, 1, 1, 12, 0, 0) - - -def serializer_from_enum(enum) -> EnumSerializer: - return EnumSerializer(enum) - - -def serializer_from_enum_member(enum_member) -> EnumSerializer: - return serializer_from_enum(type(enum_member)) - class Color(Enum): RED = "red" @@ -39,17 +29,12 @@ class Status(StrEnum): INACTIVE = auto() -class CaseInsensitiveStatus(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" +class AutoEnum(Enum): + A = auto() + B = auto() - @classmethod - def _missing_(cls, value): - if isinstance(value, str): - for member in cls: - if member.value.lower() == value.lower(): - return member - return None + +DATE = datetime(2024, 1, 1, 12, 0, 0) class MixedEnum(Enum): @@ -58,84 +43,110 @@ class MixedEnum(Enum): DATE = DATE +# By name (default) @pytest.mark.parametrize( - ("enum_member", "value"), + ("enum_class", "name", "member"), [ - (Color.RED, "red"), - (Color.GREEN, "green"), - (Priority.LOW, 1), - (Priority.HIGH, 2), - (Status.ACTIVE, "active"), - (Status.INACTIVE, "inactive"), - (MixedEnum.STRING, "hello"), - (MixedEnum.NUMBER, 42), - (MixedEnum.DATE, DATE), + (Color, "RED", Color.RED), + (Color, "GREEN", Color.GREEN), + (Priority, "LOW", Priority.LOW), + (Priority, "HIGH", Priority.HIGH), + (Status, "ACTIVE", Status.ACTIVE), + (Status, "INACTIVE", Status.INACTIVE), + (AutoEnum, "A", AutoEnum.A), + (AutoEnum, "B", AutoEnum.B), + (MixedEnum, "STRING", MixedEnum.STRING), + (MixedEnum, "DATE", MixedEnum.DATE), ], ) -def test_valid_inputs(enum_member, value): - serializer = serializer_from_enum_member(enum_member) - assert serializer.from_data(value) == Success(enum_member) - assert serializer.to_data(enum_member) == value +def test_by_name(enum_class, name, member): + s = EnumSerializer(enum_class) + assert s.from_data(name) == Success(member) + assert s.to_data(member) == name + + +@pytest.mark.parametrize("data", [123, True, None, ["a"]]) +def test_by_name_rejects_non_string_data(data): + s = EnumSerializer(Color) + expected = ExpectedStringError(data) + assert s.from_data(data) == Failure(Errors.one(expected)) + + +def test_by_name_rejects_unknown_name(): + s = EnumSerializer(Color) + expected = InvalidEnumValueError("Color", ["RED", "GREEN"], "BLUE") + assert s.from_data("BLUE") == Failure(Errors.one(expected)) + + +def test_by_name_to_data_raises_on_non_member(): + s = EnumSerializer(Color) + with pytest.raises(ValueError): + s.to_data("RED") @pytest.mark.parametrize( - ("enum", "data", "expected"), + ("enum_class", "expected"), [ - (Priority, "1", ExpectedIntegerError("1")), - (Priority, True, ExpectedIntegerError(True)), - (Status, 123, ExpectedStringError(123)), + (Color, {"type": "string", "enum": ["RED", "GREEN"]}), + (Priority, {"type": "string", "enum": ["LOW", "HIGH"]}), + (Status, {"type": "string", "enum": ["ACTIVE", "INACTIVE"]}), + (AutoEnum, {"type": "string", "enum": ["A", "B"]}), + (MixedEnum, {"type": "string", "enum": ["STRING", "NUMBER", "DATE"]}), ], ) -def test_wrong_type(enum, data, expected): - serializer = serializer_from_enum(enum) - assert serializer.from_data(data) == Failure(Errors.one(expected)) +def test_by_name_openapi_schema(enum_class, expected): + s = EnumSerializer(enum_class) + assert s.to_openapi_schema() == expected +# By value @pytest.mark.parametrize( - ("data", "enum"), - [("invalid", Color), (99, Priority), ("invalid", Status), ("invalid", CaseInsensitiveStatus)], + ("enum_class", "value", "member"), + [ + (Color, "red", Color.RED), + (Color, "green", Color.GREEN), + (Priority, 1, Priority.LOW), + (Priority, 2, Priority.HIGH), + (Status, "active", Status.ACTIVE), + (Status, "inactive", Status.INACTIVE), + (MixedEnum, "hello", MixedEnum.STRING), + (MixedEnum, 42, MixedEnum.NUMBER), + (MixedEnum, DATE, MixedEnum.DATE), + ], ) -def test_invalid_value(data, enum): - serializer = serializer_from_enum(enum) - values = [m.value for m in enum] - enum_name = enum.__name__ - - expected = Failure(Errors.one(InvalidEnumValueError(enum_name, values, data))) - assert serializer.from_data(data) == expected - - -def test_to_data_invalid(): - serializer = serializer_from_enum(Color) - - with pytest.raises(ValueError): - serializer.to_data("invalid") +def test_by_value(enum_class, value, member): + s = EnumSerializer(enum_class, by="value") + assert s.from_data(value) == Success(member) + assert s.to_data(member) == value @pytest.mark.parametrize( - ("data", "expected"), + ("enum_class", "data", "expected"), [ - ("AcTiVe", CaseInsensitiveStatus.ACTIVE), - ("active", CaseInsensitiveStatus.ACTIVE), - ("INACTIVE", CaseInsensitiveStatus.INACTIVE), + (Priority, "1", ExpectedIntegerError("1")), + (Priority, True, ExpectedIntegerError(True)), + (Status, 123, ExpectedStringError(123)), ], ) -def test_missing_method(data, expected): - serializer = serializer_from_enum(CaseInsensitiveStatus) - assert serializer.from_data(data) == Success(expected) +def test_by_value_rejects_wrong_type(enum_class, data, expected): + s = EnumSerializer(enum_class, by="value") + assert s.from_data(data) == Failure(Errors.one(expected)) -def test_invalid_enum_value_error_string(): - e = InvalidEnumValueError("Color", ["red", "green"], "invalid") - assert e.to_data() == { - "enum_name": "Color", - "values": ["red", "green"], - "actual": "invalid", - } - assert str(e) == "Expected one of ['red', 'green'] for Color, but got 'invalid'" +def test_by_value_rejects_unknown_value(): + s = EnumSerializer(Color, by="value") + expected = InvalidEnumValueError("Color", ["red", "green"], "blue") + assert s.from_data("blue") == Failure(Errors.one(expected)) + + +def test_by_value_to_data_raises_on_non_member(): + s = EnumSerializer(Color, by="value") + with pytest.raises(ValueError): + s.to_data("red") @pytest.mark.parametrize( - ("enum", "expected"), + ("enum_class", "expected"), [ (Color, {"type": "string", "enum": ["red", "green"]}), (Priority, {"type": "integer", "enum": [1, 2]}), @@ -143,6 +154,17 @@ def test_invalid_enum_value_error_string(): (MixedEnum, {"enum": ["hello", 42, DATE]}), ], ) -def test_to_openapi_schema(enum, expected): - serializer = serializer_from_enum(enum) - assert serializer.to_openapi_schema() == expected +def test_by_value_openapi_schema(enum_class, expected): + s = EnumSerializer(enum_class, by="value") + assert s.to_openapi_schema() == expected + + +# Error +def test_invalid_enum_value_error(): + error = InvalidEnumValueError("Color", ["RED", "GREEN"], "BLUE") + assert error.to_data() == { + "enum_name": "Color", + "values": ["RED", "GREEN"], + "actual": "BLUE", + } + assert str(error) == "Expected one of ['RED', 'GREEN'] for Color, but got 'BLUE'" diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 8bfa3d2..4eb0295 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -8,6 +8,21 @@ from serialite import Success, serializer +class Color(Enum): + RED = "red" + GREEN = "green" + + +class Priority(IntEnum): + LOW = 1 + HIGH = 2 + + +class Status(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + @pytest.mark.parametrize( ("data_type", "data", "value"), [ @@ -28,6 +43,9 @@ (tuple[int, str], [5, "a"], (5, "a")), (Dict[str, int], {"a": 11, "b": 22}, {"a": 11, "b": 22}), (dict[str, int], {"a": 11, "b": 22}, {"a": 11, "b": 22}), + (Color, "RED", Color.RED), + (Priority, "LOW", Priority.LOW), + (Status, "ACTIVE", Status.ACTIVE), ], ) def test_dispatch(data_type, data, value): @@ -135,33 +153,3 @@ def test_dispatch_dict_with_nested_type_alias_str_key(): dict_serializer = serializer(dict[NestedKey, int]) assert dict_serializer.from_data({"a": 1, "b": 2}) == Success({"a": 1, "b": 2}) assert dict_serializer.to_data({"a": 1, "b": 2}) == {"a": 1, "b": 2} - - -def test_dispatch_enum(): - class Color(Enum): - RED = "red" - GREEN = "green" - - color_serializer = serializer(Color) - assert color_serializer.from_data("red") == Success(Color.RED) - assert color_serializer.to_data(Color.GREEN) == "green" - - -def test_dispatch_int_enum(): - class Priority(IntEnum): - LOW = 1 - HIGH = 2 - - priority_serializer = serializer(Priority) - assert priority_serializer.from_data(1) == Success(Priority.LOW) - assert priority_serializer.to_data(Priority.HIGH) == 2 - - -def test_dispatch_str_enum(): - class Status(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" - - status_serializer = serializer(Status) - assert status_serializer.from_data("active") == Success(Status.ACTIVE) - assert status_serializer.to_data(Status.INACTIVE) == "inactive"