scim2-models 0.3.6__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. scim2_models/__init__.py +52 -52
  2. scim2_models/annotations.py +104 -0
  3. scim2_models/attributes.py +57 -0
  4. scim2_models/base.py +195 -554
  5. scim2_models/constants.py +3 -3
  6. scim2_models/context.py +168 -0
  7. scim2_models/{rfc7644 → messages}/bulk.py +4 -4
  8. scim2_models/{rfc7644 → messages}/error.py +13 -13
  9. scim2_models/messages/list_response.py +75 -0
  10. scim2_models/messages/message.py +119 -0
  11. scim2_models/messages/patch_op.py +478 -0
  12. scim2_models/{rfc7644 → messages}/search_request.py +55 -6
  13. scim2_models/reference.py +82 -0
  14. scim2_models/{rfc7643 → resources}/enterprise_user.py +4 -4
  15. scim2_models/{rfc7643 → resources}/group.py +5 -5
  16. scim2_models/resources/resource.py +468 -0
  17. scim2_models/{rfc7643 → resources}/resource_type.py +13 -22
  18. scim2_models/{rfc7643 → resources}/schema.py +51 -45
  19. scim2_models/{rfc7643 → resources}/service_provider_config.py +7 -7
  20. scim2_models/{rfc7643 → resources}/user.py +9 -9
  21. scim2_models/scim_object.py +66 -0
  22. scim2_models/urn.py +109 -0
  23. scim2_models/utils.py +108 -6
  24. {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/METADATA +1 -1
  25. scim2_models-0.4.0.dist-info/RECORD +30 -0
  26. scim2_models/rfc7643/resource.py +0 -355
  27. scim2_models/rfc7644/list_response.py +0 -136
  28. scim2_models/rfc7644/message.py +0 -10
  29. scim2_models/rfc7644/patch_op.py +0 -70
  30. scim2_models-0.3.6.dist-info/RECORD +0 -24
  31. /scim2_models/{rfc7643 → messages}/__init__.py +0 -0
  32. /scim2_models/{rfc7644 → resources}/__init__.py +0 -0
  33. {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/WHEEL +0 -0
  34. {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,11 +6,11 @@ from typing import Union
6
6
 
7
7
  from pydantic import Field
8
8
 
9
- from ..base import ComplexAttribute
10
- from ..base import MultiValuedComplexAttribute
11
- from ..base import Mutability
12
- from ..base import Reference
13
- from ..base import Required
9
+ from ..annotations import Mutability
10
+ from ..annotations import Required
11
+ from ..attributes import ComplexAttribute
12
+ from ..attributes import MultiValuedComplexAttribute
13
+ from ..reference import Reference
14
14
  from .resource import Resource
15
15
 
16
16
 
@@ -0,0 +1,468 @@
1
+ from datetime import datetime
2
+ from typing import TYPE_CHECKING
3
+ from typing import Annotated
4
+ from typing import Any
5
+ from typing import Generic
6
+ from typing import Optional
7
+ from typing import TypeVar
8
+ from typing import Union
9
+ from typing import cast
10
+ from typing import get_args
11
+ from typing import get_origin
12
+
13
+ from pydantic import Field
14
+ from pydantic import SerializationInfo
15
+ from pydantic import SerializerFunctionWrapHandler
16
+ from pydantic import WrapSerializer
17
+ from pydantic import field_serializer
18
+
19
+ from ..annotations import CaseExact
20
+ from ..annotations import Mutability
21
+ from ..annotations import Required
22
+ from ..annotations import Returned
23
+ from ..annotations import Uniqueness
24
+ from ..attributes import ComplexAttribute
25
+ from ..attributes import MultiValuedComplexAttribute
26
+ from ..attributes import is_complex_attribute
27
+ from ..base import BaseModel
28
+ from ..context import Context
29
+ from ..reference import Reference
30
+ from ..scim_object import ScimObject
31
+ from ..urn import _validate_attribute_urn
32
+ from ..utils import UNION_TYPES
33
+ from ..utils import _normalize_attribute_name
34
+
35
+ if TYPE_CHECKING:
36
+ from .schema import Attribute
37
+ from .schema import Schema
38
+
39
+
40
+ class Meta(ComplexAttribute):
41
+ """All "meta" sub-attributes are assigned by the service provider (have a "mutability" of "readOnly"), and all of these sub-attributes have a "returned" characteristic of "default".
42
+
43
+ This attribute SHALL be ignored when provided by clients. "meta" contains the following sub-attributes:
44
+ """
45
+
46
+ resource_type: Optional[str] = None
47
+ """The name of the resource type of the resource.
48
+
49
+ This attribute has a mutability of "readOnly" and "caseExact" as
50
+ "true".
51
+ """
52
+
53
+ created: Optional[datetime] = None
54
+ """The "DateTime" that the resource was added to the service provider.
55
+
56
+ This attribute MUST be a DateTime.
57
+ """
58
+
59
+ last_modified: Optional[datetime] = None
60
+ """The most recent DateTime that the details of this resource were updated
61
+ at the service provider.
62
+
63
+ If this resource has never been modified since its initial creation,
64
+ the value MUST be the same as the value of "created".
65
+ """
66
+
67
+ location: Optional[str] = None
68
+ """The URI of the resource being returned.
69
+
70
+ This value MUST be the same as the "Content-Location" HTTP response
71
+ header (see Section 3.1.4.2 of [RFC7231]).
72
+ """
73
+
74
+ version: Optional[str] = None
75
+ """The version of the resource being returned.
76
+
77
+ This value must be the same as the entity-tag (ETag) HTTP response
78
+ header (see Sections 2.1 and 2.3 of [RFC7232]). This attribute has
79
+ "caseExact" as "true". Service provider support for this attribute
80
+ is optional and subject to the service provider's support for
81
+ versioning (see Section 3.14 of [RFC7644]). If a service provider
82
+ provides "version" (entity-tag) for a representation and the
83
+ generation of that entity-tag does not satisfy all of the
84
+ characteristics of a strong validator (see Section 2.1 of
85
+ [RFC7232]), then the origin server MUST mark the "version" (entity-
86
+ tag) as weak by prefixing its opaque value with "W/" (case
87
+ sensitive).
88
+ """
89
+
90
+
91
+ class Extension(ScimObject):
92
+ @classmethod
93
+ def to_schema(cls) -> "Schema":
94
+ """Build a :class:`~scim2_models.Schema` from the current extension class."""
95
+ return _model_to_schema(cls)
96
+
97
+ @classmethod
98
+ def from_schema(cls, schema: "Schema") -> type["Extension"]:
99
+ """Build a :class:`~scim2_models.Extension` subclass from the schema definition."""
100
+ from .schema import _make_python_model
101
+
102
+ return _make_python_model(schema, cls)
103
+
104
+
105
+ AnyExtension = TypeVar("AnyExtension", bound="Extension")
106
+
107
+ _PARAMETERIZED_CLASSES: dict[tuple[type, tuple[Any, ...]], type] = {}
108
+
109
+
110
+ def _extension_serializer(
111
+ value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo
112
+ ) -> Optional[dict[str, Any]]:
113
+ """Exclude the Resource attributes from the extension dump.
114
+
115
+ For instance, attributes 'meta', 'id' or 'schemas' should not be
116
+ dumped when the model is used as an extension for another model.
117
+ """
118
+ partial_result = handler(value)
119
+ result = {
120
+ attr_name: value
121
+ for attr_name, value in partial_result.items()
122
+ if attr_name not in Resource.model_fields
123
+ }
124
+ return result or None
125
+
126
+
127
+ class Resource(ScimObject, Generic[AnyExtension]):
128
+ # Common attributes as defined by
129
+ # https://www.rfc-editor.org/rfc/rfc7643#section-3.1
130
+
131
+ id: Annotated[
132
+ Optional[str], Mutability.read_only, Returned.always, Uniqueness.global_
133
+ ] = None
134
+ """A unique identifier for a SCIM resource as defined by the service
135
+ provider.
136
+
137
+ id is mandatory is the resource representation, but is forbidden in
138
+ resource creation or replacement requests.
139
+ """
140
+
141
+ external_id: Annotated[
142
+ Optional[str], Mutability.read_write, Returned.default, CaseExact.true
143
+ ] = None
144
+ """A String that is an identifier for the resource as defined by the
145
+ provisioning client."""
146
+
147
+ meta: Annotated[Optional[Meta], Mutability.read_only, Returned.default] = None
148
+ """A complex attribute containing resource metadata."""
149
+
150
+ @classmethod
151
+ def __class_getitem__(cls, item: Any) -> type["Resource"]:
152
+ """Create a Resource class with extension fields dynamically added."""
153
+ if hasattr(cls, "__scim_extension_metadata__"):
154
+ return cls
155
+
156
+ extensions = get_args(item) if get_origin(item) in UNION_TYPES else [item]
157
+
158
+ # Skip TypeVar parameters and Any (used for generic class definitions)
159
+ valid_extensions = [
160
+ extension
161
+ for extension in extensions
162
+ if not isinstance(extension, TypeVar) and extension is not Any
163
+ ]
164
+
165
+ if not valid_extensions:
166
+ return cls
167
+
168
+ cache_key = (cls, tuple(valid_extensions))
169
+ if cache_key in _PARAMETERIZED_CLASSES:
170
+ return _PARAMETERIZED_CLASSES[cache_key]
171
+
172
+ for extension in valid_extensions:
173
+ if not (isinstance(extension, type) and issubclass(extension, Extension)):
174
+ raise TypeError(f"{extension} is not a valid Extension type")
175
+
176
+ class_name = (
177
+ f"{cls.__name__}[{', '.join(ext.__name__ for ext in valid_extensions)}]"
178
+ )
179
+
180
+ class_attrs = {"__scim_extension_metadata__": valid_extensions}
181
+
182
+ for extension in valid_extensions:
183
+ schema = extension.model_fields["schemas"].default[0]
184
+ class_attrs[extension.__name__] = Field(
185
+ default=None, # type: ignore[arg-type]
186
+ serialization_alias=schema,
187
+ validation_alias=_normalize_attribute_name(schema),
188
+ )
189
+
190
+ new_annotations = {
191
+ extension.__name__: Annotated[
192
+ Optional[extension],
193
+ WrapSerializer(_extension_serializer),
194
+ ]
195
+ for extension in valid_extensions
196
+ }
197
+
198
+ new_class = type(
199
+ class_name,
200
+ (cls,),
201
+ {
202
+ "__annotations__": new_annotations,
203
+ **class_attrs,
204
+ },
205
+ )
206
+
207
+ _PARAMETERIZED_CLASSES[cache_key] = new_class
208
+
209
+ return new_class
210
+
211
+ def __getitem__(self, item: Any) -> Optional[Extension]:
212
+ if not isinstance(item, type) or not issubclass(item, Extension):
213
+ raise KeyError(f"{item} is not a valid extension type")
214
+
215
+ return cast(Optional[Extension], getattr(self, item.__name__))
216
+
217
+ def __setitem__(self, item: Any, value: "Extension") -> None:
218
+ if not isinstance(item, type) or not issubclass(item, Extension):
219
+ raise KeyError(f"{item} is not a valid extension type")
220
+
221
+ setattr(self, item.__name__, value)
222
+
223
+ @classmethod
224
+ def get_extension_models(cls) -> dict[str, type[Extension]]:
225
+ """Return extension a dict associating extension models with their schemas."""
226
+ extension_models = getattr(cls, "__scim_extension_metadata__", [])
227
+ by_schema = {
228
+ ext.model_fields["schemas"].default[0]: ext for ext in extension_models
229
+ }
230
+ return by_schema
231
+
232
+ @classmethod
233
+ def get_extension_model(
234
+ cls, name_or_schema: Union[str, "Schema"]
235
+ ) -> Optional[type[Extension]]:
236
+ """Return an extension by its name or schema."""
237
+ for schema, extension in cls.get_extension_models().items():
238
+ if schema == name_or_schema or extension.__name__ == name_or_schema:
239
+ return extension
240
+ return None
241
+
242
+ @staticmethod
243
+ def get_by_schema(
244
+ resource_types: list[type["Resource"]],
245
+ schema: str,
246
+ with_extensions: bool = True,
247
+ ) -> Optional[Union[type["Resource"], type["Extension"]]]:
248
+ """Given a resource type list and a schema, find the matching resource type."""
249
+ by_schema: dict[str, Union[type[Resource], type[Extension]]] = {
250
+ resource_type.model_fields["schemas"].default[0].lower(): resource_type
251
+ for resource_type in (resource_types or [])
252
+ }
253
+ if with_extensions:
254
+ for resource_type in resource_types:
255
+ by_schema.update(
256
+ {
257
+ schema.lower(): extension
258
+ for schema, extension in resource_type.get_extension_models().items()
259
+ }
260
+ )
261
+
262
+ return by_schema.get(schema.lower())
263
+
264
+ @staticmethod
265
+ def get_by_payload(
266
+ resource_types: list[type["Resource"]],
267
+ payload: dict[str, Any],
268
+ **kwargs: Any,
269
+ ) -> Optional[type]:
270
+ """Given a resource type list and a payload, find the matching resource type."""
271
+ if not payload or not payload.get("schemas"):
272
+ return None
273
+
274
+ schema = payload["schemas"][0]
275
+ return Resource.get_by_schema(resource_types, schema, **kwargs)
276
+
277
+ @field_serializer("schemas")
278
+ def set_extension_schemas(
279
+ self, schemas: Annotated[list[str], Required.true]
280
+ ) -> list[str]:
281
+ """Add model extension ids to the 'schemas' attribute."""
282
+ extension_schemas = self.get_extension_models().keys()
283
+ schemas = self.schemas + [
284
+ schema for schema in extension_schemas if schema not in self.schemas
285
+ ]
286
+ return schemas
287
+
288
+ @classmethod
289
+ def to_schema(cls) -> "Schema":
290
+ """Build a :class:`~scim2_models.Schema` from the current resource class."""
291
+ return _model_to_schema(cls)
292
+
293
+ @classmethod
294
+ def from_schema(cls, schema: "Schema") -> type["Resource"]:
295
+ """Build a :class:`scim2_models.Resource` subclass from the schema definition."""
296
+ from .schema import _make_python_model
297
+
298
+ return _make_python_model(schema, cls)
299
+
300
+ def _prepare_model_dump(
301
+ self,
302
+ scim_ctx: Optional[Context] = Context.DEFAULT,
303
+ attributes: Optional[list[str]] = None,
304
+ excluded_attributes: Optional[list[str]] = None,
305
+ **kwargs: Any,
306
+ ) -> dict[str, Any]:
307
+ kwargs = super()._prepare_model_dump(scim_ctx, **kwargs)
308
+
309
+ # RFC 7644: "SHOULD ignore any query parameters they do not recognize"
310
+ kwargs["context"]["scim_attributes"] = [
311
+ valid_attr
312
+ for attribute in (attributes or [])
313
+ if (valid_attr := _validate_attribute_urn(attribute, self.__class__))
314
+ is not None
315
+ ]
316
+ kwargs["context"]["scim_excluded_attributes"] = [
317
+ valid_attr
318
+ for attribute in (excluded_attributes or [])
319
+ if (valid_attr := _validate_attribute_urn(attribute, self.__class__))
320
+ is not None
321
+ ]
322
+ return kwargs
323
+
324
+ def model_dump(
325
+ self,
326
+ *args: Any,
327
+ scim_ctx: Optional[Context] = Context.DEFAULT,
328
+ attributes: Optional[list[str]] = None,
329
+ excluded_attributes: Optional[list[str]] = None,
330
+ **kwargs: Any,
331
+ ) -> dict[str, Any]:
332
+ """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
333
+
334
+ :param scim_ctx: If a SCIM context is passed, some default values of
335
+ Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
336
+ messages. Pass :data:`None` to get the default Pydantic behavior.
337
+ :param attributes: A multi-valued list of strings indicating the names of resource
338
+ attributes to return in the response, overriding the set of attributes that
339
+ would be returned by default. Invalid values are ignored.
340
+ :param excluded_attributes: A multi-valued list of strings indicating the names of resource
341
+ attributes to be removed from the default set of attributes to return. Invalid values are ignored.
342
+ """
343
+ dump_kwargs = self._prepare_model_dump(
344
+ scim_ctx, attributes, excluded_attributes, **kwargs
345
+ )
346
+ if scim_ctx:
347
+ dump_kwargs.setdefault("mode", "json")
348
+ return super(ScimObject, self).model_dump(*args, **dump_kwargs)
349
+
350
+ def model_dump_json(
351
+ self,
352
+ *args: Any,
353
+ scim_ctx: Optional[Context] = Context.DEFAULT,
354
+ attributes: Optional[list[str]] = None,
355
+ excluded_attributes: Optional[list[str]] = None,
356
+ **kwargs: Any,
357
+ ) -> str:
358
+ """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
359
+
360
+ :param scim_ctx: If a SCIM context is passed, some default values of
361
+ Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
362
+ messages. Pass :data:`None` to get the default Pydantic behavior.
363
+ :param attributes: A multi-valued list of strings indicating the names of resource
364
+ attributes to return in the response, overriding the set of attributes that
365
+ would be returned by default. Invalid values are ignored.
366
+ :param excluded_attributes: A multi-valued list of strings indicating the names of resource
367
+ attributes to be removed from the default set of attributes to return. Invalid values are ignored.
368
+ """
369
+ dump_kwargs = self._prepare_model_dump(
370
+ scim_ctx, attributes, excluded_attributes, **kwargs
371
+ )
372
+ return super(ScimObject, self).model_dump_json(*args, **dump_kwargs)
373
+
374
+
375
+ AnyResource = TypeVar("AnyResource", bound="Resource")
376
+
377
+
378
+ def _dedicated_attributes(
379
+ model: type[BaseModel], excluded_models: list[type[BaseModel]]
380
+ ) -> dict[str, Any]:
381
+ """Return attributes that are not members the parent 'excluded_models'."""
382
+
383
+ def compare_field_infos(fi1: Any, fi2: Any) -> bool:
384
+ return (
385
+ fi1
386
+ and fi2
387
+ and fi1.__slotnames__ == fi2.__slotnames__
388
+ and all(
389
+ getattr(fi1, attr) == getattr(fi2, attr) for attr in fi1.__slotnames__
390
+ )
391
+ )
392
+
393
+ parent_field_infos = {
394
+ field_name: field_info
395
+ for excluded_model in excluded_models
396
+ for field_name, field_info in excluded_model.model_fields.items()
397
+ }
398
+ field_infos = {
399
+ field_name: field_info
400
+ for field_name, field_info in model.model_fields.items()
401
+ if not compare_field_infos(field_info, parent_field_infos.get(field_name))
402
+ }
403
+ return field_infos
404
+
405
+
406
+ def _model_to_schema(model: type[BaseModel]) -> "Schema":
407
+ from scim2_models.resources.schema import Schema
408
+
409
+ schema_urn = model.model_fields["schemas"].default[0]
410
+ field_infos = _dedicated_attributes(model, [Resource])
411
+ attributes = [
412
+ _model_attribute_to_scim_attribute(model, attribute_name)
413
+ for attribute_name in field_infos
414
+ if attribute_name != "schemas"
415
+ ]
416
+ schema = Schema(
417
+ name=model.__name__,
418
+ id=schema_urn,
419
+ description=model.__doc__ or model.__name__,
420
+ attributes=attributes,
421
+ )
422
+ return schema
423
+
424
+
425
+ def _model_attribute_to_scim_attribute(
426
+ model: type[BaseModel], attribute_name: str
427
+ ) -> "Attribute":
428
+ from scim2_models.resources.schema import Attribute
429
+
430
+ field_info = model.model_fields[attribute_name]
431
+ root_type = model.get_field_root_type(attribute_name)
432
+ if root_type is None:
433
+ raise ValueError(
434
+ f"Could not determine root type for attribute {attribute_name}"
435
+ )
436
+ attribute_type = Attribute.Type.from_python(root_type)
437
+ sub_attributes = (
438
+ [
439
+ _model_attribute_to_scim_attribute(root_type, sub_attribute_name)
440
+ for sub_attribute_name in _dedicated_attributes(
441
+ root_type,
442
+ [MultiValuedComplexAttribute],
443
+ )
444
+ if (
445
+ attribute_name != "sub_attributes"
446
+ or sub_attribute_name != "sub_attributes"
447
+ )
448
+ ]
449
+ if root_type and is_complex_attribute(root_type)
450
+ else None
451
+ )
452
+
453
+ return Attribute(
454
+ name=field_info.serialization_alias or attribute_name,
455
+ type=Attribute.Type(attribute_type),
456
+ multi_valued=model.get_field_multiplicity(attribute_name),
457
+ description=field_info.description,
458
+ canonical_values=field_info.examples,
459
+ required=model.get_field_annotation(attribute_name, Required),
460
+ case_exact=model.get_field_annotation(attribute_name, CaseExact),
461
+ mutability=model.get_field_annotation(attribute_name, Mutability),
462
+ returned=model.get_field_annotation(attribute_name, Returned),
463
+ uniqueness=model.get_field_annotation(attribute_name, Uniqueness),
464
+ sub_attributes=sub_attributes,
465
+ reference_types=Reference.get_types(root_type)
466
+ if attribute_type == Attribute.Type.reference
467
+ else None,
468
+ )
@@ -1,19 +1,16 @@
1
1
  from typing import Annotated
2
2
  from typing import Optional
3
- from typing import get_args
4
- from typing import get_origin
5
3
 
6
4
  from pydantic import Field
7
5
  from typing_extensions import Self
8
6
 
9
- from ..base import CaseExact
10
- from ..base import ComplexAttribute
11
- from ..base import Mutability
12
- from ..base import Reference
13
- from ..base import Required
14
- from ..base import Returned
15
- from ..base import URIReference
16
- from ..utils import UNION_TYPES
7
+ from ..annotations import CaseExact
8
+ from ..annotations import Mutability
9
+ from ..annotations import Required
10
+ from ..annotations import Returned
11
+ from ..attributes import ComplexAttribute
12
+ from ..reference import Reference
13
+ from ..reference import URIReference
17
14
  from .resource import Resource
18
15
 
19
16
 
@@ -85,21 +82,15 @@ class ResourceType(Resource):
85
82
  """Build a naive ResourceType from a resource model."""
86
83
  schema = resource_model.model_fields["schemas"].default[0]
87
84
  name = schema.split(":")[-1]
88
- if resource_model.__pydantic_generic_metadata__["args"]:
89
- extensions = resource_model.__pydantic_generic_metadata__["args"][0]
90
- extensions = (
91
- get_args(extensions)
92
- if get_origin(extensions) in UNION_TYPES
93
- else [extensions]
94
- )
95
- else:
96
- extensions = []
97
-
98
- return ResourceType(
85
+
86
+ # Get extensions from the metadata system
87
+ extensions = getattr(resource_model, "__scim_extension_metadata__", [])
88
+
89
+ return cls(
99
90
  id=name,
100
91
  name=name,
101
92
  description=name,
102
- endpoint=f"/{name}s",
93
+ endpoint=Reference[URIReference](f"/{name}s"),
103
94
  schema_=schema,
104
95
  schema_extensions=[
105
96
  SchemaExtension(