Skip to content

Commit 14e7bc4

Browse files
committed
fix: allow non canonical values for Enums
1 parent b7be1d2 commit 14e7bc4

File tree

4 files changed

+112
-6
lines changed

4 files changed

+112
-6
lines changed

doc/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[0.4.3] - Unreleased
5+
--------------------
6+
7+
Fixed
8+
^^^^^
9+
- Allow non canonical values for enums. :issue:`34`
10+
411
[0.4.2] - 2025-08-05
512
--------------------
613

scim2_models/attributes.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
from enum import Enum
12
from inspect import isclass
23
from typing import Annotated
34
from typing import Any
45
from typing import Optional
56
from typing import get_origin
67

8+
try:
9+
from typing import Self
10+
except ImportError:
11+
from typing_extensions import Self
12+
713
from pydantic import Field
814

915
from .annotations import Mutability
@@ -13,6 +19,35 @@
1319
from .reference import Reference
1420

1521

22+
class ExtensibleStringEnum(str, Enum):
23+
"""String enum that accepts arbitrary values while preserving canonical ones.
24+
25+
This enum allows both predefined canonical values and arbitrary string values,
26+
conforming to :rfc:`RFC 7643 <7643>` which permits service providers to accept
27+
additional type values beyond the recommended set.
28+
"""
29+
30+
def __str__(self) -> str:
31+
"""Return just the string value, not the enum representation."""
32+
return str(self.value)
33+
34+
@classmethod
35+
def _missing_(cls, value: Any) -> Self:
36+
"""Handle unknown enum values by creating dynamic instances.
37+
38+
:param value: The value to create an enum instance for
39+
:return: A new enum instance for the given value
40+
:raises ValueError: If value is not a string
41+
"""
42+
if isinstance(value, str):
43+
# Create a pseudo enum member for unknown string values
44+
obj = str.__new__(cls, value)
45+
obj._name_ = value
46+
obj._value_ = value
47+
return obj
48+
raise ValueError(f"{value} is not a valid string value for {cls.__name__}")
49+
50+
1651
class ComplexAttribute(BaseModel):
1752
"""A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""
1853

scim2_models/resources/user.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from enum import Enum
21
from typing import Annotated
32
from typing import ClassVar
43
from typing import Literal
@@ -14,6 +13,7 @@
1413
from ..annotations import Returned
1514
from ..annotations import Uniqueness
1615
from ..attributes import ComplexAttribute
16+
from ..attributes import ExtensibleStringEnum
1717
from ..attributes import MultiValuedComplexAttribute
1818
from ..reference import ExternalReference
1919
from ..reference import Reference
@@ -49,7 +49,7 @@ class Name(ComplexAttribute):
4949

5050

5151
class Email(MultiValuedComplexAttribute):
52-
class Type(str, Enum):
52+
class Type(ExtensibleStringEnum):
5353
work = "work"
5454
home = "home"
5555
other = "other"
@@ -70,7 +70,7 @@ class Type(str, Enum):
7070

7171

7272
class PhoneNumber(MultiValuedComplexAttribute):
73-
class Type(str, Enum):
73+
class Type(ExtensibleStringEnum):
7474
work = "work"
7575
home = "home"
7676
mobile = "mobile"
@@ -97,7 +97,7 @@ class Type(str, Enum):
9797

9898

9999
class Im(MultiValuedComplexAttribute):
100-
class Type(str, Enum):
100+
class Type(ExtensibleStringEnum):
101101
aim = "aim"
102102
gtalk = "gtalk"
103103
icq = "icq"
@@ -125,7 +125,7 @@ class Type(str, Enum):
125125

126126

127127
class Photo(MultiValuedComplexAttribute):
128-
class Type(str, Enum):
128+
class Type(ExtensibleStringEnum):
129129
photo = "photo"
130130
thumbnail = "thumbnail"
131131

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

146146

147147
class Address(MultiValuedComplexAttribute):
148-
class Type(str, Enum):
148+
class Type(ExtensibleStringEnum):
149149
work = "work"
150150
home = "home"
151151
other = "other"

tests/test_user.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import datetime
22

3+
import pytest
4+
35
from scim2_models import Address
46
from scim2_models import Email
57
from scim2_models import Im
68
from scim2_models import PhoneNumber
79
from scim2_models import Photo
810
from scim2_models import Reference
911
from scim2_models import User
12+
from scim2_models.attributes import ExtensibleStringEnum
1013

1114

1215
def test_minimal_user(load_sample):
@@ -124,3 +127,64 @@ def test_full_user(load_sample):
124127
obj.meta.location
125128
== "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646"
126129
)
130+
131+
132+
def test_extensible_enum_canonical_values():
133+
"""Test that canonical enum values work as expected."""
134+
135+
class TestEnum(ExtensibleStringEnum):
136+
foo = "foo"
137+
bar = "bar"
138+
139+
assert TestEnum.foo == "foo"
140+
assert TestEnum.bar == "bar"
141+
assert str(TestEnum.foo) == "foo"
142+
143+
144+
def test_extensible_enum_arbitrary_values():
145+
"""Test that arbitrary string values are accepted."""
146+
147+
class TestEnum(ExtensibleStringEnum):
148+
foo = "foo"
149+
bar = "bar"
150+
151+
# Create instances with arbitrary values
152+
custom = TestEnum("custom_value")
153+
another = TestEnum("another_value")
154+
155+
assert str(custom) == "custom_value"
156+
assert str(another) == "another_value"
157+
assert custom == "custom_value"
158+
assert another == "another_value"
159+
160+
161+
def test_extensible_enum_non_string_rejected():
162+
"""Test that non-string values are rejected."""
163+
164+
class TestEnum(ExtensibleStringEnum):
165+
foo = "foo"
166+
167+
with pytest.raises(ValueError, match="is not a valid string value"):
168+
TestEnum(123)
169+
170+
with pytest.raises(ValueError, match="is not a valid string value"):
171+
TestEnum(None)
172+
173+
174+
def test_complex_attribute_extensible_types():
175+
"""Test that complex attribute types support RFC 7643 extensibility."""
176+
# Test with Email - canonical and arbitrary types
177+
email_canonical = Email(value="[email protected]", type=Email.Type.work)
178+
assert str(email_canonical.type) == "work"
179+
180+
email_custom = Email(value="[email protected]", type="company") # Issue #34 case
181+
assert str(email_custom.type) == "company"
182+
183+
# Test serialization works correctly
184+
data = email_custom.model_dump()
185+
assert data["type"] == "company"
186+
187+
# Test round-trip serialization
188+
restored = Email.model_validate(data)
189+
assert str(restored.type) == "company"
190+
assert restored.value == "[email protected]"

0 commit comments

Comments
 (0)