Skip to content

Commit 7514c8b

Browse files
committed
refactor: Resources must define a 'scim_schema' attribute
1 parent 9236228 commit 7514c8b

23 files changed

+153
-75
lines changed

doc/changelog.rst

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

4+
[0.3.0] - Unreleased
5+
--------------------
6+
7+
.. warning::
8+
9+
This version comes with breaking changes:
10+
11+
- :class:`~scim2_models.Resource`, :class:`~scim2_models.Extension` and :class:`~scim2_models.Message` must define a ``scim_schema`` attribute.
12+
13+
.. code-block:: python
14+
:caption: Before
15+
16+
class MyResource(Resource):
17+
schemas : list[str] = ["example:schema:MyResource"]
18+
19+
.. code-block:: python
20+
:caption: After
21+
22+
class MyResource(Resource):
23+
scim_schema: ClassVar[str] = "example:schema:MyResource"
24+
25+
426
[0.2.7] - 2024-11-30
527
--------------------
628

doc/tutorial.rst

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,12 @@ Custom models
272272

273273
You can write your own model and use it the same way than the other scim2-models models.
274274
Just inherit from :class:`~scim2_models.Resource` for your main resource, or :class:`~scim2_models.Extension` for extensions.
275+
Then you need to define a ``scim_schema`` attribute, that is a class variable detailing the schema identifier of your model.
275276
Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes:
276277

277278
.. code-block:: python
278279
279-
>>> from typing import Annotated, Optional, List
280+
>>> from typing import Annotated, ClassVar, Optional, List
280281
>>> from scim2_models import Resource, Returned, Mutability, ComplexAttribute
281282
>>> from enum import Enum
282283
@@ -288,7 +289,7 @@ Use :class:`~scim2_models.ComplexAttribute` as base class for complex attributes
288289
... """The pet color."""
289290
290291
>>> class Pet(Resource):
291-
... schemas: List[str] = ["example:schemas:Pet"]
292+
... scim_schema : ClassVar[str] = "example:schemas:Pet"
292293
...
293294
... name: Annotated[Optional[str], Mutability.immutable, Returned.always]
294295
... """The name of the pet."""
@@ -309,6 +310,8 @@ that can take type parameters to represent :rfc:`RFC7643 §7 'referenceTypes'<7
309310

310311
>>> from typing import Literal
311312
>>> class PetOwner(Resource):
313+
... scim_schema : ClassVar[str] = "examples:schema.PetOwner"
314+
...
312315
... pet: Reference[Literal["Pet"]]
313316

314317
:class:`~scim2_models.Reference` has two special type parameters :data:`~scim2_models.ExternalReference` and :data:`~scim2_models.URIReference` that matches :rfc:`RFC7643 §7 <7643#section-7>` external and URI reference types.
@@ -325,7 +328,7 @@ This is useful for server implementations, so custom models or models provided b
325328
>>> class MyCustomResource(Resource):
326329
... """My awesome custom schema."""
327330
...
328-
... schemas: List[str] = ["example:schemas:MyCustomResource"]
331+
... scim_schema: ClassVar[str] = "example:schemas:MyCustomResource"
329332
...
330333
... foobar: Optional[str]
331334
...

scim2_models/base.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,7 @@ def validate_attribute_urn(
9292
if default_resource and default_resource not in resource_types:
9393
resource_types.append(default_resource)
9494

95-
default_schema = (
96-
default_resource.model_fields["schemas"].default[0]
97-
if default_resource
98-
else None
99-
)
95+
default_schema = default_resource.scim_schema if default_resource else None
10096

10197
schema: Optional[Any]
10298
schema, attribute_base = extract_schema_and_attribute_base(attribute_name)
@@ -613,20 +609,17 @@ def mark_with_schema(self):
613609
if not is_complex_attribute(attr_type):
614610
continue
615611

616-
main_schema = (
617-
getattr(self, "_schema", None)
618-
or self.model_fields["schemas"].default[0]
619-
)
612+
main_schema = getattr(self, "_scim_schema", None) or self.scim_schema
620613

621614
separator = ":" if isinstance(self, Resource) else "."
622615
schema = f"{main_schema}{separator}{field_name}"
623616

624617
if attr_value := getattr(self, field_name):
625618
if isinstance(attr_value, list):
626619
for item in attr_value:
627-
item._schema = schema
620+
item._scim_schema = schema
628621
else:
629-
attr_value._schema = schema
622+
attr_value._scim_schema = schema
630623

631624
@field_serializer("*", mode="wrap")
632625
def scim_serializer(
@@ -793,7 +786,7 @@ def get_attribute_urn(self, field_name: str) -> str:
793786
794787
See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
795788
"""
796-
main_schema = self.model_fields["schemas"].default[0]
789+
main_schema = self.scim_schema
797790
alias = self.model_fields[field_name].serialization_alias or field_name
798791

799792
# if alias contains a ':' this is an extension urn
@@ -804,15 +797,15 @@ def get_attribute_urn(self, field_name: str) -> str:
804797
class ComplexAttribute(BaseModel):
805798
"""A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""
806799

807-
_schema: Optional[str] = None
800+
_scim_schema: Optional[str] = None
808801

809802
def get_attribute_urn(self, field_name: str) -> str:
810803
"""Build the full URN of the attribute.
811804
812805
See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
813806
"""
814807
alias = self.model_fields[field_name].serialization_alias or field_name
815-
return f"{self._schema}.{alias}"
808+
return f"{self._scim_schema}.{alias}"
816809

817810

818811
class MultiValuedComplexAttribute(ComplexAttribute):

scim2_models/rfc7643/enterprise_user.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Annotated
2+
from typing import ClassVar
23
from typing import Literal
34
from typing import Optional
45

@@ -26,7 +27,9 @@ class Manager(ComplexAttribute):
2627

2728

2829
class EnterpriseUser(Extension):
29-
schemas: list[str] = ["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
30+
scim_schema: ClassVar[str] = (
31+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
32+
)
3033

3134
employee_number: Optional[str] = None
3235
"""Numeric or alphanumeric identifier assigned to a person, typically based

scim2_models/rfc7643/group.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Annotated
2+
from typing import ClassVar
23
from typing import Literal
34
from typing import Optional
45
from typing import Union
@@ -31,7 +32,7 @@ class GroupMember(MultiValuedComplexAttribute):
3132

3233

3334
class Group(Resource):
34-
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:Group"]
35+
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:Group"
3536

3637
display_name: Optional[str] = None
3738
"""A human-readable name for the Group."""

scim2_models/rfc7643/resource.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ class Meta(ComplexAttribute):
7878

7979

8080
class Extension(BaseModel):
81+
def __init_subclass__(cls, **kwargs):
82+
super().__init_subclass__(**kwargs)
83+
if not hasattr(cls, "scim_schema"):
84+
raise AttributeError(
85+
f"{cls.__name__} did not define a scim_schema attribute"
86+
)
87+
8188
@classmethod
8289
def to_schema(cls):
8390
"""Build a :class:`~scim2_models.Schema` from the current extension class."""
@@ -120,7 +127,7 @@ def __new__(cls, name, bases, attrs, **kwargs):
120127
else [extensions]
121128
)
122129
for extension in extensions:
123-
schema = extension.model_fields["schemas"].default[0]
130+
schema = extension.scim_schema
124131
attrs.setdefault("__annotations__", {})[extension.__name__] = Annotated[
125132
Optional[extension],
126133
WrapSerializer(extension_serializer),
@@ -136,6 +143,18 @@ def __new__(cls, name, bases, attrs, **kwargs):
136143

137144

138145
class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
146+
def __init_subclass__(cls, **kwargs):
147+
super().__init_subclass__(**kwargs)
148+
if not hasattr(cls, "scim_schema"):
149+
raise AttributeError(
150+
f"{cls.__name__} did not define a scim_schema attribute"
151+
)
152+
153+
def init_schemas():
154+
return [cls.scim_schema]
155+
156+
cls.model_fields["schemas"].default_factory = init_schemas
157+
139158
schemas: list[str]
140159
"""The "schemas" attribute is a REQUIRED attribute and is an array of
141160
Strings containing URIs that are used to indicate the namespaces of the
@@ -186,9 +205,7 @@ def get_extension_models(cls) -> dict[str, type]:
186205
else extension_models
187206
)
188207

189-
by_schema = {
190-
ext.model_fields["schemas"].default[0]: ext for ext in extension_models
191-
}
208+
by_schema = {ext.scim_schema: ext for ext in extension_models}
192209
return by_schema
193210

194211
@staticmethod
@@ -197,7 +214,7 @@ def get_by_schema(
197214
) -> Optional[type]:
198215
"""Given a resource type list and a schema, find the matching resource type."""
199216
by_schema = {
200-
resource_type.model_fields["schemas"].default[0].lower(): resource_type
217+
resource_type.scim_schema.lower(): resource_type
201218
for resource_type in (resource_types or [])
202219
}
203220
if with_extensions:
@@ -274,7 +291,7 @@ def compare_field_infos(fi1, fi2):
274291
def model_to_schema(model: type[BaseModel]):
275292
from scim2_models.rfc7643.schema import Schema
276293

277-
schema_urn = model.model_fields["schemas"].default[0]
294+
schema_urn = model.scim_schema
278295
field_infos = dedicated_attributes(model)
279296
attributes = [
280297
model_attribute_to_attribute(model, attribute_name)

scim2_models/rfc7643/resource_type.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from typing import Annotated
2+
from typing import ClassVar
23
from typing import Optional
34

45
from pydantic import Field
@@ -35,7 +36,7 @@ class SchemaExtension(ComplexAttribute):
3536

3637

3738
class ResourceType(Resource):
38-
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"]
39+
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
3940

4041
name: Annotated[Optional[str], Mutability.read_only, Required.true] = None
4142
"""The resource type name.
@@ -78,7 +79,7 @@ class ResourceType(Resource):
7879
@classmethod
7980
def from_resource(cls, resource_model: type[Resource]) -> Self:
8081
"""Build a naive ResourceType from a resource model."""
81-
schema = resource_model.model_fields["schemas"].default[0]
82+
schema = resource_model.scim_schema
8283
name = schema.split(":")[-1]
8384
extensions = resource_model.__pydantic_generic_metadata__["args"]
8485
return ResourceType(
@@ -88,9 +89,7 @@ def from_resource(cls, resource_model: type[Resource]) -> Self:
8889
endpoint=f"/{name}s",
8990
schema_=schema,
9091
schema_extensions=[
91-
SchemaExtension(
92-
schema_=extension.model_fields["schemas"].default[0], required=False
93-
)
92+
SchemaExtension(schema_=extension.scim_schema, required=False)
9493
for extension in extensions
9594
],
9695
)

scim2_models/rfc7643/schema.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from enum import Enum
44
from typing import Annotated
55
from typing import Any
6+
from typing import ClassVar
67
from typing import List # noqa : UP005
78
from typing import Literal
89
from typing import Optional
@@ -64,10 +65,7 @@ def make_python_model(
6465
for attr in (obj.attributes or [])
6566
if attr.name
6667
}
67-
pydantic_attributes["schemas"] = (
68-
Optional[list[str]],
69-
Field(default=[obj.id]),
70-
)
68+
pydantic_attributes["scim_schema"] = (ClassVar[str], obj.id)
7169

7270
model_name = to_pascal(to_snake(obj.name))
7371
model = create_model(model_name, __base__=base, **pydantic_attributes)
@@ -240,7 +238,7 @@ def to_python(self) -> Optional[tuple[Any, Field]]:
240238

241239

242240
class Schema(Resource):
243-
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:Schema"]
241+
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:Schema"
244242

245243
id: Annotated[Optional[str], Mutability.read_only, Required.true] = None
246244
"""The unique URI of the schema."""

scim2_models/rfc7643/service_provider_config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from enum import Enum
22
from typing import Annotated
3+
from typing import ClassVar
34
from typing import Optional
45

56
from pydantic import Field
@@ -94,7 +95,9 @@ class Type(str, Enum):
9495

9596

9697
class ServiceProviderConfig(Resource):
97-
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]
98+
scim_schema: ClassVar[str] = (
99+
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
100+
)
98101

99102
id: Annotated[
100103
Optional[str], Mutability.read_only, Returned.default, Uniqueness.global_

scim2_models/rfc7643/user.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from enum import Enum
22
from typing import Annotated
3+
from typing import ClassVar
34
from typing import Literal
45
from typing import Optional
56
from typing import Union
@@ -214,7 +215,7 @@ class X509Certificate(MultiValuedComplexAttribute):
214215

215216

216217
class User(Resource):
217-
schemas: list[str] = ["urn:ietf:params:scim:schemas:core:2.0:User"]
218+
scim_schema: ClassVar[str] = "urn:ietf:params:scim:schemas:core:2.0:User"
218219

219220
user_name: Annotated[Optional[str], Uniqueness.server, Required.true] = None
220221
"""Unique identifier for the User, typically used by the user to directly

0 commit comments

Comments
 (0)