Skip to content

Commit e546026

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

File tree

4 files changed

+222
-6
lines changed

4 files changed

+222
-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: 174 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,174 @@ 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_email_canonical_and_arbitrary_types():
175+
"""Test Email with both canonical and arbitrary type values per RFC 7643."""
176+
# Canonical types
177+
email = Email(value="[email protected]", type=Email.Type.work)
178+
assert email.type == Email.Type.work
179+
assert str(email.type) == "work"
180+
181+
email = Email(value="[email protected]", type=Email.Type.home)
182+
assert email.type == Email.Type.home
183+
assert str(email.type) == "home"
184+
185+
# Arbitrary types per RFC 7643
186+
email = Email(value="[email protected]", type="custom_type")
187+
assert str(email.type) == "custom_type"
188+
189+
email = Email(value="[email protected]", type="business")
190+
assert str(email.type) == "business"
191+
192+
193+
def test_phone_canonical_and_arbitrary_types():
194+
"""Test PhoneNumber with both canonical and arbitrary type values per RFC 7643."""
195+
# Canonical types
196+
phone = PhoneNumber(value="+1234567890", type=PhoneNumber.Type.mobile)
197+
assert phone.type == PhoneNumber.Type.mobile
198+
assert str(phone.type) == "mobile"
199+
200+
# Arbitrary types per RFC 7643
201+
phone = PhoneNumber(value="+1234567890", type="voip")
202+
assert str(phone.type) == "voip"
203+
204+
phone = PhoneNumber(value="+1234567890", type="satellite")
205+
assert str(phone.type) == "satellite"
206+
207+
208+
def test_address_canonical_and_arbitrary_types():
209+
"""Test Address with both canonical and arbitrary type values per RFC 7643."""
210+
# Canonical types
211+
addr = Address(formatted="123 Main St", type=Address.Type.home)
212+
assert addr.type == Address.Type.home
213+
assert str(addr.type) == "home"
214+
215+
# Arbitrary types per RFC 7643
216+
addr = Address(formatted="123 Main St", type="vacation")
217+
assert str(addr.type) == "vacation"
218+
219+
addr = Address(formatted="123 Main St", type="shipping")
220+
assert str(addr.type) == "shipping"
221+
222+
223+
def test_im_canonical_and_arbitrary_types():
224+
"""Test Im with both canonical and arbitrary type values per RFC 7643."""
225+
# Canonical types
226+
im = Im(value="[email protected]", type=Im.Type.skype)
227+
assert im.type == Im.Type.skype
228+
assert str(im.type) == "skype"
229+
230+
# Arbitrary types per RFC 7643
231+
im = Im(value="[email protected]", type="discord")
232+
assert str(im.type) == "discord"
233+
234+
im = Im(value="[email protected]", type="teams")
235+
assert str(im.type) == "teams"
236+
237+
238+
def test_photo_canonical_and_arbitrary_types():
239+
"""Test Photo with both canonical and arbitrary type values per RFC 7643."""
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+
253+
def test_type_serialization_compatibility():
254+
"""Test that serialization works with both canonical and arbitrary values."""
255+
# Canonical type
256+
email_canonical = Email(value="[email protected]", type=Email.Type.work)
257+
data = email_canonical.model_dump()
258+
assert data["type"] == "work"
259+
260+
# Arbitrary type
261+
email_custom = Email(value="[email protected]", type="custom_business")
262+
data = email_custom.model_dump()
263+
assert data["type"] == "custom_business"
264+
265+
266+
def test_round_trip_serialization():
267+
"""Test that arbitrary values can be serialized and deserialized correctly."""
268+
# Test with arbitrary type
269+
original = Email(value="[email protected]", type="custom_type")
270+
data = original.model_dump()
271+
restored = Email.model_validate(data)
272+
273+
assert str(restored.type) == "custom_type"
274+
assert restored.type == "custom_type"
275+
assert restored.value == "[email protected]"
276+
277+
278+
def test_rfc_7643_compliance():
279+
"""Test compliance with RFC 7643 extensibility requirements.
280+
281+
RFC 7643 states that service providers MAY allow additional type values
282+
beyond the canonical ones like "work", "home", and "other".
283+
"""
284+
# Should accept canonical values
285+
email = Email(value="[email protected]", type="work")
286+
assert str(email.type) == "work"
287+
288+
phone = PhoneNumber(value="+1234567890", type="mobile")
289+
assert str(phone.type) == "mobile"
290+
291+
# Should accept additional values as specified in RFC
292+
email = Email(value="[email protected]", type="emergency")
293+
assert str(email.type) == "emergency"
294+
295+
phone = PhoneNumber(value="+1234567890", type="voip")
296+
assert str(phone.type) == "voip"
297+
298+
# Test GitHub issue #34 specific case
299+
email_company = Email(value="[email protected]", type="company")
300+
assert str(email_company.type) == "company"

0 commit comments

Comments
 (0)