diff --git a/doc/changelog.rst b/doc/changelog.rst index 7c2dae2..c70f56a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +[0.4.3] - Unreleased +-------------------- + +Fixed +^^^^^ +- Allow non canonical values for enums. :issue:`34` + [0.4.2] - 2025-08-05 -------------------- diff --git a/scim2_models/attributes.py b/scim2_models/attributes.py index d32bbb0..096d30d 100644 --- a/scim2_models/attributes.py +++ b/scim2_models/attributes.py @@ -1,9 +1,15 @@ +from enum import Enum from inspect import isclass from typing import Annotated from typing import Any from typing import Optional from typing import get_origin +try: + from typing import Self +except ImportError: + from typing_extensions import Self + from pydantic import Field from .annotations import Mutability @@ -13,6 +19,35 @@ from .reference import Reference +class ExtensibleStringEnum(str, Enum): + """String enum that accepts arbitrary values while preserving canonical ones. + + This enum allows both predefined canonical values and arbitrary string values, + conforming to :rfc:`RFC 7643 <7643>` which permits service providers to accept + additional type values beyond the recommended set. + """ + + def __str__(self) -> str: + """Return just the string value, not the enum representation.""" + return str(self.value) + + @classmethod + def _missing_(cls, value: Any) -> Self: + """Handle unknown enum values by creating dynamic instances. + + :param value: The value to create an enum instance for + :return: A new enum instance for the given value + :raises ValueError: If value is not a string + """ + if isinstance(value, str): + # Create a pseudo enum member for unknown string values + obj = str.__new__(cls, value) + obj._name_ = value + obj._value_ = value + return obj + raise ValueError(f"{value} is not a valid string value for {cls.__name__}") + + class ComplexAttribute(BaseModel): """A complex attribute as defined in :rfc:`RFC7643 ยง2.3.8 <7643#section-2.3.8>`.""" diff --git a/scim2_models/resources/user.py b/scim2_models/resources/user.py index 91931da..61a1851 100644 --- a/scim2_models/resources/user.py +++ b/scim2_models/resources/user.py @@ -1,4 +1,3 @@ -from enum import Enum from typing import Annotated from typing import ClassVar from typing import Literal @@ -14,6 +13,7 @@ from ..annotations import Returned from ..annotations import Uniqueness from ..attributes import ComplexAttribute +from ..attributes import ExtensibleStringEnum from ..attributes import MultiValuedComplexAttribute from ..reference import ExternalReference from ..reference import Reference @@ -49,7 +49,7 @@ class Name(ComplexAttribute): class Email(MultiValuedComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" other = "other" @@ -70,7 +70,7 @@ class Type(str, Enum): class PhoneNumber(MultiValuedComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" mobile = "mobile" @@ -97,7 +97,7 @@ class Type(str, Enum): class Im(MultiValuedComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): aim = "aim" gtalk = "gtalk" icq = "icq" @@ -125,7 +125,7 @@ class Type(str, Enum): class Photo(MultiValuedComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): photo = "photo" thumbnail = "thumbnail" @@ -145,7 +145,7 @@ class Type(str, Enum): class Address(MultiValuedComplexAttribute): - class Type(str, Enum): + class Type(ExtensibleStringEnum): work = "work" home = "home" other = "other" diff --git a/tests/test_user.py b/tests/test_user.py index 656f26d..ebd4e6e 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,5 +1,7 @@ import datetime +import pytest + from scim2_models import Address from scim2_models import Email from scim2_models import Im @@ -7,6 +9,7 @@ from scim2_models import Photo from scim2_models import Reference from scim2_models import User +from scim2_models.attributes import ExtensibleStringEnum def test_minimal_user(load_sample): @@ -124,3 +127,64 @@ def test_full_user(load_sample): obj.meta.location == "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646" ) + + +def test_extensible_enum_canonical_values(): + """Test that canonical enum values work as expected.""" + + class TestEnum(ExtensibleStringEnum): + foo = "foo" + bar = "bar" + + assert TestEnum.foo == "foo" + assert TestEnum.bar == "bar" + assert str(TestEnum.foo) == "foo" + + +def test_extensible_enum_arbitrary_values(): + """Test that arbitrary string values are accepted.""" + + class TestEnum(ExtensibleStringEnum): + foo = "foo" + bar = "bar" + + # Create instances with arbitrary values + custom = TestEnum("custom_value") + another = TestEnum("another_value") + + assert str(custom) == "custom_value" + assert str(another) == "another_value" + assert custom == "custom_value" + assert another == "another_value" + + +def test_extensible_enum_non_string_rejected(): + """Test that non-string values are rejected.""" + + class TestEnum(ExtensibleStringEnum): + foo = "foo" + + with pytest.raises(ValueError, match="is not a valid string value"): + TestEnum(123) + + with pytest.raises(ValueError, match="is not a valid string value"): + TestEnum(None) + + +def test_complex_attribute_extensible_types(): + """Test that complex attribute types support RFC 7643 extensibility.""" + # Test with Email - canonical and arbitrary types + email_canonical = Email(value="test@example.com", type=Email.Type.work) + assert str(email_canonical.type) == "work" + + email_custom = Email(value="john.doe@example.com", type="company") # Issue #34 case + assert str(email_custom.type) == "company" + + # Test serialization works correctly + data = email_custom.model_dump() + assert data["type"] == "company" + + # Test round-trip serialization + restored = Email.model_validate(data) + assert str(restored.type) == "company" + assert restored.value == "john.doe@example.com"