Skip to content

Commit ab767aa

Browse files
authored
Merge pull request #109 from python-scim/72-multi-valued-complex-attributes
Explicit ComplexAttribute sub-attributes
2 parents 895dc3d + 11533f5 commit ab767aa

File tree

10 files changed

+93
-69
lines changed

10 files changed

+93
-69
lines changed

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Fixed
88
^^^^^
99
- Attributes with ``None`` type are excluded from Schema generation.
1010
- Allow PATCH operations on resources and extensions root path.
11+
- Multiple ComplexAttribute do not inherit from MultiValuedComplexAttribute by default. :issue:`72` :issue:`73`
1112

1213
[0.4.2] - 2025-08-05
1314
--------------------

samples/rfc7643-8.7.2-schema-service_provider_configuration.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,24 @@
177177
"returned": "default",
178178
"mutability": "readOnly",
179179
"subAttributes": [
180+
{
181+
"name": "type",
182+
"type": "string",
183+
"multiValued": false,
184+
"description": "The authentication scheme.",
185+
"required": true,
186+
"canonicalValues": [
187+
"oauth",
188+
"oauth2",
189+
"oauthbearertoken",
190+
"httpbasic",
191+
"httpdigest"
192+
],
193+
"caseExact": false,
194+
"mutability": "readOnly",
195+
"returned": "default",
196+
"uniqueness": "none"
197+
},
180198
{
181199
"name": "name",
182200
"type": "string",
@@ -226,6 +244,17 @@
226244
"mutability": "readOnly",
227245
"returned": "default",
228246
"uniqueness": "none"
247+
},
248+
{
249+
"name": "primary",
250+
"type": "boolean",
251+
"multiValued": false,
252+
"description": "A Boolean value indicating the 'primary' or preferred attribute value for this attribute.",
253+
"required": false,
254+
"caseExact": false,
255+
"mutability": "readOnly",
256+
"returned": "default",
257+
"uniqueness": "none"
229258
}
230259
]
231260
}

scim2_models/attributes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,5 @@ def is_complex_attribute(type_: type) -> bool:
5353
return (
5454
get_origin(type_) != Reference
5555
and isclass(type_)
56-
and issubclass(type_, (ComplexAttribute, MultiValuedComplexAttribute))
56+
and issubclass(type_, ComplexAttribute)
5757
)

scim2_models/resources/group.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010
from ..annotations import Mutability
1111
from ..annotations import Required
1212
from ..attributes import ComplexAttribute
13-
from ..attributes import MultiValuedComplexAttribute
1413
from ..reference import Reference
1514
from .resource import Resource
1615

1716

18-
class GroupMember(MultiValuedComplexAttribute):
17+
class GroupMember(ComplexAttribute):
1918
value: Annotated[Optional[str], Mutability.immutable] = None
2019
"""Identifier of the member of this Group."""
2120

scim2_models/resources/resource.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from ..annotations import Returned
2323
from ..annotations import Uniqueness
2424
from ..attributes import ComplexAttribute
25-
from ..attributes import MultiValuedComplexAttribute
2625
from ..attributes import is_complex_attribute
2726
from ..base import BaseModel
2827
from ..context import Context
@@ -438,10 +437,7 @@ def _model_attribute_to_scim_attribute(
438437
sub_attributes = (
439438
[
440439
_model_attribute_to_scim_attribute(root_type, sub_attribute_name)
441-
for sub_attribute_name in _dedicated_attributes(
442-
root_type,
443-
[MultiValuedComplexAttribute],
444-
)
440+
for sub_attribute_name in root_type.model_fields # type: ignore
445441
if (
446442
attribute_name != "sub_attributes"
447443
or sub_attribute_name != "sub_attributes"

scim2_models/resources/schema.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from ..annotations import Returned
2424
from ..annotations import Uniqueness
2525
from ..attributes import ComplexAttribute
26-
from ..attributes import MultiValuedComplexAttribute
2726
from ..attributes import is_complex_attribute
2827
from ..base import BaseModel
2928
from ..constants import RESERVED_WORDS
@@ -49,7 +48,6 @@ def _make_python_identifier(identifier: str) -> str:
4948
def _make_python_model(
5049
obj: Union["Schema", "Attribute"],
5150
base: type[T],
52-
multiple: bool = False,
5351
) -> type[T]:
5452
"""Build a Python model from a Schema or an Attribute object."""
5553
if isinstance(obj, Attribute):
@@ -99,7 +97,6 @@ class Type(str, Enum):
9997

10098
def _to_python(
10199
self,
102-
multiple: bool = False,
103100
reference_types: Optional[list[str]] = None,
104101
) -> type:
105102
if self.value == self.reference and reference_types is not None:
@@ -119,9 +116,7 @@ def _to_python(
119116
self.integer: int,
120117
self.date_time: datetime,
121118
self.binary: Base64Bytes,
122-
self.complex: MultiValuedComplexAttribute
123-
if multiple
124-
else ComplexAttribute,
119+
self.complex: ComplexAttribute,
125120
}
126121
return attr_types[self.value]
127122

@@ -215,12 +210,10 @@ def _to_python(self) -> Optional[tuple[Any, Any]]:
215210
if not self.name or not self.type:
216211
return None
217212

218-
attr_type = self.type._to_python(bool(self.multi_valued), self.reference_types)
213+
attr_type = self.type._to_python(self.reference_types)
219214

220-
if attr_type in (ComplexAttribute, MultiValuedComplexAttribute):
221-
attr_type = _make_python_model(
222-
obj=self, base=attr_type, multiple=bool(self.multi_valued)
223-
)
215+
if attr_type == ComplexAttribute:
216+
attr_type = _make_python_model(obj=self, base=attr_type)
224217

225218
if self.multi_valued:
226219
attr_type = list[attr_type] # type: ignore

scim2_models/resources/service_provider_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class Filter(ComplexAttribute):
3838
"""A Boolean value specifying whether or not the operation is supported."""
3939

4040
max_results: Annotated[Optional[int], Mutability.read_only, Required.true] = None
41-
"""A Boolean value specifying whether or not the operation is supported."""
41+
"""An integer value specifying the maximum number of resources returned in a response."""
4242

4343

4444
class ChangePassword(ComplexAttribute):
@@ -66,7 +66,7 @@ class Type(str, Enum):
6666

6767
type: Annotated[Optional[Type], Mutability.read_only, Required.true] = Field(
6868
None,
69-
examples=["oauth", "oauth2", "oauthbreakertoken", "httpbasic", "httpdigest"],
69+
examples=["oauth", "oauth2", "oauthbearertoken", "httpbasic", "httpdigest"],
7070
)
7171
"""The authentication scheme."""
7272

scim2_models/resources/user.py

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from ..annotations import Returned
1515
from ..annotations import Uniqueness
1616
from ..attributes import ComplexAttribute
17-
from ..attributes import MultiValuedComplexAttribute
1817
from ..reference import ExternalReference
1918
from ..reference import Reference
2019
from ..utils import Base64Bytes
@@ -48,7 +47,7 @@ class Name(ComplexAttribute):
4847
languages (e.g., 'III' given the full name 'Ms. Barbara J Jensen, III')."""
4948

5049

51-
class Email(MultiValuedComplexAttribute):
50+
class Email(ComplexAttribute):
5251
class Type(str, Enum):
5352
work = "work"
5453
home = "home"
@@ -69,7 +68,7 @@ class Type(str, Enum):
6968
address."""
7069

7170

72-
class PhoneNumber(MultiValuedComplexAttribute):
71+
class PhoneNumber(ComplexAttribute):
7372
class Type(str, Enum):
7473
work = "work"
7574
home = "home"
@@ -96,7 +95,7 @@ class Type(str, Enum):
9695
number."""
9796

9897

99-
class Im(MultiValuedComplexAttribute):
98+
class Im(ComplexAttribute):
10099
class Type(str, Enum):
101100
aim = "aim"
102101
gtalk = "gtalk"
@@ -124,7 +123,7 @@ class Type(str, Enum):
124123
for this attribute, e.g., the preferred messenger or primary messenger."""
125124

126125

127-
class Photo(MultiValuedComplexAttribute):
126+
class Photo(ComplexAttribute):
128127
class Type(str, Enum):
129128
photo = "photo"
130129
thumbnail = "thumbnail"
@@ -144,7 +143,7 @@ class Type(str, Enum):
144143
for this attribute, e.g., the preferred photo or thumbnail."""
145144

146145

147-
class Address(MultiValuedComplexAttribute):
146+
class Address(ComplexAttribute):
148147
class Type(str, Enum):
149148
work = "work"
150149
home = "home"
@@ -181,11 +180,22 @@ class Type(str, Enum):
181180
for this attribute, e.g., the preferred photo or thumbnail."""
182181

183182

184-
class Entitlement(MultiValuedComplexAttribute):
185-
pass
183+
class Entitlement(ComplexAttribute):
184+
value: Optional[str] = None
185+
"""The value of an entitlement."""
186+
187+
display: Optional[str] = None
188+
"""A human-readable name, primarily used for display purposes."""
189+
190+
type: Optional[str] = None
191+
"""A label indicating the attribute's function."""
192+
193+
primary: Optional[bool] = None
194+
"""A Boolean value indicating the 'primary' or preferred attribute value
195+
for this attribute."""
186196

187197

188-
class GroupMembership(MultiValuedComplexAttribute):
198+
class GroupMembership(ComplexAttribute):
189199
value: Annotated[Optional[str], Mutability.read_only] = None
190200
"""The identifier of the User's group."""
191201

@@ -206,14 +216,35 @@ class GroupMembership(MultiValuedComplexAttribute):
206216
'indirect'."""
207217

208218

209-
class Role(MultiValuedComplexAttribute):
210-
pass
219+
class Role(ComplexAttribute):
220+
value: Optional[str] = None
221+
"""The value of a role."""
222+
223+
display: Optional[str] = None
224+
"""A human-readable name, primarily used for display purposes."""
211225

226+
type: Optional[str] = None
227+
"""A label indicating the attribute's function."""
212228

213-
class X509Certificate(MultiValuedComplexAttribute):
229+
primary: Optional[bool] = None
230+
"""A Boolean value indicating the 'primary' or preferred attribute value
231+
for this attribute."""
232+
233+
234+
class X509Certificate(ComplexAttribute):
214235
value: Annotated[Optional[Base64Bytes], CaseExact.true] = None
215236
"""The value of an X.509 certificate."""
216237

238+
display: Optional[str] = None
239+
"""A human-readable name, primarily used for display purposes."""
240+
241+
type: Optional[str] = None
242+
"""A label indicating the attribute's function."""
243+
244+
primary: Optional[bool] = None
245+
"""A Boolean value indicating the 'primary' or preferred attribute value
246+
for this attribute."""
247+
217248

218249
class User(Resource[AnyExtension]):
219250
schemas: Annotated[list[str], Required.true] = [

tests/test_dynamic_resources.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from scim2_models.annotations import Returned
99
from scim2_models.annotations import Uniqueness
1010
from scim2_models.attributes import ComplexAttribute
11-
from scim2_models.attributes import MultiValuedComplexAttribute
1211
from scim2_models.reference import ExternalReference
1312
from scim2_models.reference import Reference
1413
from scim2_models.reference import URIReference
@@ -408,7 +407,7 @@ def test_make_user_model_from_schema(load_sample):
408407
# emails
409408
Emails = User.get_field_root_type("emails")
410409
assert Emails == User.Emails
411-
assert issubclass(Emails, MultiValuedComplexAttribute)
410+
assert issubclass(Emails, ComplexAttribute)
412411
assert User.get_field_multiplicity("emails")
413412
assert (
414413
User.model_fields["emails"].description
@@ -476,7 +475,7 @@ def test_make_user_model_from_schema(load_sample):
476475
# phone_numbers
477476
PhoneNumbers = User.get_field_root_type("phone_numbers")
478477
assert PhoneNumbers == User.PhoneNumbers
479-
assert issubclass(PhoneNumbers, MultiValuedComplexAttribute)
478+
assert issubclass(PhoneNumbers, ComplexAttribute)
480479
assert User.get_field_multiplicity("phone_numbers")
481480
assert (
482481
User.model_fields["phone_numbers"].description
@@ -560,7 +559,7 @@ def test_make_user_model_from_schema(load_sample):
560559
# ims
561560
Ims = User.get_field_root_type("ims")
562561
assert Ims == User.Ims
563-
assert issubclass(Ims, MultiValuedComplexAttribute)
562+
assert issubclass(Ims, ComplexAttribute)
564563
assert User.get_field_multiplicity("ims")
565564
assert (
566565
User.model_fields["ims"].description
@@ -637,7 +636,7 @@ def test_make_user_model_from_schema(load_sample):
637636
# photos
638637
Photos = User.get_field_root_type("photos")
639638
assert Photos == User.Photos
640-
assert issubclass(Photos, MultiValuedComplexAttribute)
639+
assert issubclass(Photos, ComplexAttribute)
641640
assert User.get_field_multiplicity("photos")
642641
assert User.model_fields["photos"].description == "URLs of photos of the User."
643642
assert User.get_field_annotation("photos", Required) == Required.false
@@ -699,7 +698,7 @@ def test_make_user_model_from_schema(load_sample):
699698
# addresses
700699
Addresses = User.get_field_root_type("addresses")
701700
assert Addresses == User.Addresses
702-
assert issubclass(Addresses, MultiValuedComplexAttribute)
701+
assert issubclass(Addresses, ComplexAttribute)
703702
assert User.get_field_multiplicity("addresses")
704703
assert (
705704
User.model_fields["addresses"].description
@@ -837,7 +836,7 @@ def test_make_user_model_from_schema(load_sample):
837836
# groups
838837
Groups = User.get_field_root_type("groups")
839838
assert Groups == User.Groups
840-
assert issubclass(Groups, MultiValuedComplexAttribute)
839+
assert issubclass(Groups, ComplexAttribute)
841840
assert User.get_field_multiplicity("groups")
842841
assert (
843842
User.model_fields["groups"].description
@@ -911,7 +910,7 @@ def test_make_user_model_from_schema(load_sample):
911910
# entitlements
912911
Entitlements = User.get_field_root_type("entitlements")
913912
assert Entitlements == User.Entitlements
914-
assert issubclass(Entitlements, MultiValuedComplexAttribute)
913+
assert issubclass(Entitlements, ComplexAttribute)
915914
assert User.get_field_multiplicity("entitlements")
916915
assert (
917916
User.model_fields["entitlements"].description
@@ -989,7 +988,7 @@ def test_make_user_model_from_schema(load_sample):
989988
# roles
990989
Roles = User.get_field_root_type("roles")
991990
assert Roles == User.Roles
992-
assert issubclass(Roles, MultiValuedComplexAttribute)
991+
assert issubclass(Roles, ComplexAttribute)
993992
assert User.get_field_multiplicity("roles")
994993
assert (
995994
User.model_fields["roles"].description
@@ -1053,7 +1052,7 @@ def test_make_user_model_from_schema(load_sample):
10531052
# x_509_certificates
10541053
X509Certificates = User.get_field_root_type("x_509_certificates")
10551054
assert X509Certificates == User.X509Certificates
1056-
assert issubclass(X509Certificates, MultiValuedComplexAttribute)
1055+
assert issubclass(X509Certificates, ComplexAttribute)
10571056
assert User.get_field_multiplicity("x_509_certificates")
10581057
assert (
10591058
User.model_fields["x_509_certificates"].description
@@ -2210,7 +2209,7 @@ def test_make_schema_model_from_schema(load_sample):
22102209
# attributes
22112210
Attributes = Schema_.get_field_root_type("attributes")
22122211
assert Attributes == Schema_.Attributes
2213-
assert issubclass(Attributes, MultiValuedComplexAttribute)
2212+
assert issubclass(Attributes, ComplexAttribute)
22142213
assert Schema_.get_field_multiplicity("attributes")
22152214
assert (
22162215
Schema_.model_fields["attributes"].description
@@ -2440,7 +2439,7 @@ def test_make_schema_model_from_schema(load_sample):
24402439
# sub_attributes
24412440
SubAttributes = Attributes.get_field_root_type("sub_attributes")
24422441
assert SubAttributes == Attributes.SubAttributes
2443-
assert issubclass(SubAttributes, MultiValuedComplexAttribute)
2442+
assert issubclass(SubAttributes, ComplexAttribute)
24442443
assert Attributes.get_field_multiplicity("sub_attributes")
24452444
assert (
24462445
Attributes.model_fields["sub_attributes"].description

0 commit comments

Comments
 (0)