Skip to content

Allow non canonical values for Enums #106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

[0.4.3] - Unreleased
--------------------

Fixed
^^^^^
- Allow non canonical values for enums. :issue:`34`

[0.4.2] - 2025-08-05
--------------------

Expand Down
35 changes: 35 additions & 0 deletions scim2_models/attributes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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>`."""

Expand Down
12 changes: 6 additions & 6 deletions scim2_models/resources/user.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from enum import Enum
from typing import Annotated
from typing import ClassVar
from typing import Literal
Expand All @@ -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
Expand Down Expand Up @@ -49,7 +49,7 @@ class Name(ComplexAttribute):


class Email(MultiValuedComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
other = "other"
Expand All @@ -70,7 +70,7 @@ class Type(str, Enum):


class PhoneNumber(MultiValuedComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
mobile = "mobile"
Expand All @@ -97,7 +97,7 @@ class Type(str, Enum):


class Im(MultiValuedComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
aim = "aim"
gtalk = "gtalk"
icq = "icq"
Expand Down Expand Up @@ -125,7 +125,7 @@ class Type(str, Enum):


class Photo(MultiValuedComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
photo = "photo"
thumbnail = "thumbnail"

Expand All @@ -145,7 +145,7 @@ class Type(str, Enum):


class Address(MultiValuedComplexAttribute):
class Type(str, Enum):
class Type(ExtensibleStringEnum):
work = "work"
home = "home"
other = "other"
Expand Down
64 changes: 64 additions & 0 deletions tests/test_user.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import datetime

import pytest

from scim2_models import Address
from scim2_models import Email
from scim2_models import Im
from scim2_models import PhoneNumber
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):
Expand Down Expand Up @@ -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="[email protected]", type=Email.Type.work)
assert str(email_canonical.type) == "work"

email_custom = Email(value="[email protected]", 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 == "[email protected]"
Loading