Skip to content

Commit c143584

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

File tree

4 files changed

+219
-6
lines changed

4 files changed

+219
-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: 171 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,171 @@ 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+
class TestExtensibleStringEnum:
133+
"""Test the base ExtensibleStringEnum behavior."""
134+
135+
def test_canonical_values(self):
136+
"""Test that canonical enum values work as expected."""
137+
138+
class TestEnum(ExtensibleStringEnum):
139+
foo = "foo"
140+
bar = "bar"
141+
142+
assert TestEnum.foo == "foo"
143+
assert TestEnum.bar == "bar"
144+
assert str(TestEnum.foo) == "foo"
145+
146+
def test_arbitrary_values(self):
147+
"""Test that arbitrary string values are accepted."""
148+
149+
class TestEnum(ExtensibleStringEnum):
150+
foo = "foo"
151+
bar = "bar"
152+
153+
# Create instances with arbitrary values
154+
custom = TestEnum("custom_value")
155+
another = TestEnum("another_value")
156+
157+
assert str(custom) == "custom_value"
158+
assert str(another) == "another_value"
159+
assert custom == "custom_value"
160+
assert another == "another_value"
161+
162+
def test_non_string_values_rejected(self):
163+
"""Test that non-string values are rejected."""
164+
165+
class TestEnum(ExtensibleStringEnum):
166+
foo = "foo"
167+
168+
with pytest.raises(ValueError, match="is not a valid string value"):
169+
TestEnum(123)
170+
171+
with pytest.raises(ValueError, match="is not a valid string value"):
172+
TestEnum(None)
173+
174+
175+
class TestComplexAttributeExtensibleTypes:
176+
"""Test that complex attribute Type classes accept arbitrary values per RFC 7643."""
177+
178+
def test_email_canonical_and_arbitrary_types(self):
179+
"""Test Email with both canonical and arbitrary type values."""
180+
# Canonical types
181+
email = Email(value="[email protected]", type=Email.Type.work)
182+
assert email.type == Email.Type.work
183+
assert str(email.type) == "work"
184+
185+
email = Email(value="[email protected]", type=Email.Type.home)
186+
assert email.type == Email.Type.home
187+
assert str(email.type) == "home"
188+
189+
# Arbitrary types per RFC 7643
190+
email = Email(value="[email protected]", type="custom_type")
191+
assert str(email.type) == "custom_type"
192+
193+
email = Email(value="[email protected]", type="business")
194+
assert str(email.type) == "business"
195+
196+
def test_phone_canonical_and_arbitrary_types(self):
197+
"""Test PhoneNumber with both canonical and arbitrary type values."""
198+
# Canonical types
199+
phone = PhoneNumber(value="+1234567890", type=PhoneNumber.Type.mobile)
200+
assert phone.type == PhoneNumber.Type.mobile
201+
assert str(phone.type) == "mobile"
202+
203+
# Arbitrary types per RFC 7643
204+
phone = PhoneNumber(value="+1234567890", type="voip")
205+
assert str(phone.type) == "voip"
206+
207+
phone = PhoneNumber(value="+1234567890", type="satellite")
208+
assert str(phone.type) == "satellite"
209+
210+
def test_address_canonical_and_arbitrary_types(self):
211+
"""Test Address with both canonical and arbitrary type values."""
212+
# Canonical types
213+
addr = Address(formatted="123 Main St", type=Address.Type.home)
214+
assert addr.type == Address.Type.home
215+
assert str(addr.type) == "home"
216+
217+
# Arbitrary types per RFC 7643
218+
addr = Address(formatted="123 Main St", type="vacation")
219+
assert str(addr.type) == "vacation"
220+
221+
addr = Address(formatted="123 Main St", type="shipping")
222+
assert str(addr.type) == "shipping"
223+
224+
def test_im_canonical_and_arbitrary_types(self):
225+
"""Test Im with both canonical and arbitrary type values."""
226+
# Canonical types
227+
im = Im(value="[email protected]", type=Im.Type.skype)
228+
assert im.type == Im.Type.skype
229+
assert str(im.type) == "skype"
230+
231+
# Arbitrary types per RFC 7643
232+
im = Im(value="[email protected]", type="discord")
233+
assert str(im.type) == "discord"
234+
235+
im = Im(value="[email protected]", type="teams")
236+
assert str(im.type) == "teams"
237+
238+
def test_photo_canonical_and_arbitrary_types(self):
239+
"""Test Photo with both canonical and arbitrary type values."""
240+
# Canonical types
241+
photo = Photo(value="http://example.com/photo.jpg", type=Photo.Type.photo)
242+
assert photo.type == Photo.Type.photo
243+
assert str(photo.type) == "photo"
244+
245+
# Arbitrary types per RFC 7643
246+
photo = Photo(value="http://example.com/avatar.jpg", type="avatar")
247+
assert str(photo.type) == "avatar"
248+
249+
photo = Photo(value="http://example.com/profile.jpg", type="profile")
250+
assert str(photo.type) == "profile"
251+
252+
def test_type_serialization_compatibility(self):
253+
"""Test that serialization works with both canonical and arbitrary values."""
254+
# Canonical type
255+
email_canonical = Email(value="[email protected]", type=Email.Type.work)
256+
data = email_canonical.model_dump()
257+
assert data["type"] == "work"
258+
259+
# Arbitrary type
260+
email_custom = Email(value="[email protected]", type="custom_business")
261+
data = email_custom.model_dump()
262+
assert data["type"] == "custom_business"
263+
264+
def test_round_trip_serialization(self):
265+
"""Test that arbitrary values can be serialized and deserialized correctly."""
266+
# Test with arbitrary type
267+
original = Email(value="[email protected]", type="custom_type")
268+
data = original.model_dump()
269+
restored = Email.model_validate(data)
270+
271+
assert str(restored.type) == "custom_type"
272+
assert restored.type == "custom_type"
273+
assert restored.value == "[email protected]"
274+
275+
def test_rfc_7643_compliance(self):
276+
"""Test compliance with RFC 7643 extensibility requirements.
277+
278+
RFC 7643 states that service providers MAY allow additional type values
279+
beyond the canonical ones like "work", "home", and "other".
280+
"""
281+
# Should accept canonical values
282+
email = Email(value="[email protected]", type="work")
283+
assert str(email.type) == "work"
284+
285+
phone = PhoneNumber(value="+1234567890", type="mobile")
286+
assert str(phone.type) == "mobile"
287+
288+
# Should accept additional values as specified in RFC
289+
email = Email(value="[email protected]", type="emergency")
290+
assert str(email.type) == "emergency"
291+
292+
phone = PhoneNumber(value="+1234567890", type="voip")
293+
assert str(phone.type) == "voip"
294+
295+
# Test GitHub issue #34 specific case
296+
email_company = Email(value="[email protected]", type="company")
297+
assert str(email_company.type) == "company"

0 commit comments

Comments
 (0)