scim2-models 0.3.6__py3-none-any.whl → 0.3.7__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.
@@ -1,31 +1,41 @@
1
1
  from datetime import datetime
2
+ from typing import TYPE_CHECKING
2
3
  from typing import Annotated
3
4
  from typing import Any
4
5
  from typing import Generic
5
6
  from typing import Optional
6
7
  from typing import TypeVar
8
+ from typing import Union
9
+ from typing import cast
7
10
  from typing import get_args
8
11
  from typing import get_origin
9
12
 
10
13
  from pydantic import Field
14
+ from pydantic import SerializationInfo
15
+ from pydantic import SerializerFunctionWrapHandler
11
16
  from pydantic import WrapSerializer
12
17
  from pydantic import field_serializer
13
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
14
27
  from ..base import BaseModel
15
- from ..base import BaseModelType
16
- from ..base import CaseExact
17
- from ..base import ComplexAttribute
18
- from ..base import ExternalReference
19
- from ..base import MultiValuedComplexAttribute
20
- from ..base import Mutability
21
- from ..base import Required
22
- from ..base import Returned
23
- from ..base import Uniqueness
24
- from ..base import URIReference
25
- from ..base import is_complex_attribute
28
+ from ..context import Context
29
+ from ..reference import Reference
30
+ from ..scim_object import ScimObject
31
+ from ..scim_object import validate_attribute_urn
26
32
  from ..utils import UNION_TYPES
27
33
  from ..utils import normalize_attribute_name
28
34
 
35
+ if TYPE_CHECKING:
36
+ from .schema import Attribute
37
+ from .schema import Schema
38
+
29
39
 
30
40
  class Meta(ComplexAttribute):
31
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".
@@ -78,14 +88,14 @@ class Meta(ComplexAttribute):
78
88
  """
79
89
 
80
90
 
81
- class Extension(BaseModel):
91
+ class Extension(ScimObject):
82
92
  @classmethod
83
- def to_schema(cls):
93
+ def to_schema(cls) -> "Schema":
84
94
  """Build a :class:`~scim2_models.Schema` from the current extension class."""
85
95
  return model_to_schema(cls)
86
96
 
87
97
  @classmethod
88
- def from_schema(cls, schema) -> "Extension":
98
+ def from_schema(cls, schema: "Schema") -> type["Extension"]:
89
99
  """Build a :class:`~scim2_models.Extension` subclass from the schema definition."""
90
100
  from .schema import make_python_model
91
101
 
@@ -94,14 +104,18 @@ class Extension(BaseModel):
94
104
 
95
105
  AnyExtension = TypeVar("AnyExtension", bound="Extension")
96
106
 
107
+ _PARAMETERIZED_CLASSES: dict[tuple[type, tuple[Any, ...]], type] = {}
108
+
97
109
 
98
- def extension_serializer(value: Any, handler, info) -> Optional[dict[str, Any]]:
110
+ def extension_serializer(
111
+ value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo
112
+ ) -> Optional[dict[str, Any]]:
99
113
  """Exclude the Resource attributes from the extension dump.
100
114
 
101
115
  For instance, attributes 'meta', 'id' or 'schemas' should not be
102
116
  dumped when the model is used as an extension for another model.
103
117
  """
104
- partial_result = handler(value, info)
118
+ partial_result = handler(value)
105
119
  result = {
106
120
  attr_name: value
107
121
  for attr_name, value in partial_result.items()
@@ -110,39 +124,7 @@ def extension_serializer(value: Any, handler, info) -> Optional[dict[str, Any]]:
110
124
  return result or None
111
125
 
112
126
 
113
- class ResourceMetaclass(BaseModelType):
114
- def __new__(cls, name, bases, attrs, **kwargs):
115
- """Dynamically add a field for each extension."""
116
- if "__pydantic_generic_metadata__" in kwargs:
117
- extensions = kwargs["__pydantic_generic_metadata__"]["args"][0]
118
- extensions = (
119
- get_args(extensions)
120
- if get_origin(extensions) in UNION_TYPES
121
- else [extensions]
122
- )
123
- for extension in extensions:
124
- schema = extension.model_fields["schemas"].default[0]
125
- attrs.setdefault("__annotations__", {})[extension.__name__] = Annotated[
126
- Optional[extension],
127
- WrapSerializer(extension_serializer),
128
- ]
129
- attrs[extension.__name__] = Field(
130
- None,
131
- serialization_alias=schema,
132
- validation_alias=normalize_attribute_name(schema),
133
- )
134
-
135
- klass = super().__new__(cls, name, bases, attrs, **kwargs)
136
- return klass
137
-
138
-
139
- class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
140
- schemas: Annotated[list[str], Required.true]
141
- """The "schemas" attribute is a REQUIRED attribute and is an array of
142
- Strings containing URIs that are used to indicate the namespaces of the
143
- SCIM schemas that define the attributes present in the current JSON
144
- structure."""
145
-
127
+ class Resource(ScimObject, Generic[AnyExtension]):
146
128
  # Common attributes as defined by
147
129
  # https://www.rfc-editor.org/rfc/rfc7643#section-3.1
148
130
 
@@ -165,13 +147,74 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
165
147
  meta: Annotated[Optional[Meta], Mutability.read_only, Returned.default] = None
166
148
  """A complex attribute containing resource metadata."""
167
149
 
168
- def __getitem__(self, item: Any):
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]:
169
212
  if not isinstance(item, type) or not issubclass(item, Extension):
170
213
  raise KeyError(f"{item} is not a valid extension type")
171
214
 
172
- return getattr(self, item.__name__)
215
+ return cast(Optional[Extension], getattr(self, item.__name__))
173
216
 
174
- def __setitem__(self, item: Any, value: "Resource"):
217
+ def __setitem__(self, item: Any, value: "Extension") -> None:
175
218
  if not isinstance(item, type) or not issubclass(item, Extension):
176
219
  raise KeyError(f"{item} is not a valid extension type")
177
220
 
@@ -180,21 +223,16 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
180
223
  @classmethod
181
224
  def get_extension_models(cls) -> dict[str, type[Extension]]:
182
225
  """Return extension a dict associating extension models with their schemas."""
183
- extension_models = cls.__pydantic_generic_metadata__.get("args", [])
184
- extension_models = (
185
- get_args(extension_models[0])
186
- if len(extension_models) == 1
187
- and get_origin(extension_models[0]) in UNION_TYPES
188
- else extension_models
189
- )
190
-
226
+ extension_models = getattr(cls, "__scim_extension_metadata__", [])
191
227
  by_schema = {
192
228
  ext.model_fields["schemas"].default[0]: ext for ext in extension_models
193
229
  }
194
230
  return by_schema
195
231
 
196
232
  @classmethod
197
- def get_extension_model(cls, name_or_schema) -> Optional[type[Extension]]:
233
+ def get_extension_model(
234
+ cls, name_or_schema: Union[str, "Schema"]
235
+ ) -> Optional[type[Extension]]:
198
236
  """Return an extension by its name or schema."""
199
237
  for schema, extension in cls.get_extension_models().items():
200
238
  if schema == name_or_schema or extension.__name__ == name_or_schema:
@@ -203,15 +241,17 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
203
241
 
204
242
  @staticmethod
205
243
  def get_by_schema(
206
- resource_types: list[type[BaseModel]], schema: str, with_extensions=True
207
- ) -> Optional[type]:
244
+ resource_types: list[type["Resource"]],
245
+ schema: str,
246
+ with_extensions: bool = True,
247
+ ) -> Optional[Union[type["Resource"], type["Extension"]]]:
208
248
  """Given a resource type list and a schema, find the matching resource type."""
209
- by_schema = {
249
+ by_schema: dict[str, Union[type[Resource], type[Extension]]] = {
210
250
  resource_type.model_fields["schemas"].default[0].lower(): resource_type
211
251
  for resource_type in (resource_types or [])
212
252
  }
213
253
  if with_extensions:
214
- for resource_type in list(by_schema.values()):
254
+ for resource_type in resource_types:
215
255
  by_schema.update(
216
256
  {
217
257
  schema.lower(): extension
@@ -222,7 +262,11 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
222
262
  return by_schema.get(schema.lower())
223
263
 
224
264
  @staticmethod
225
- def get_by_payload(resource_types: list[type], payload: dict, **kwargs):
265
+ def get_by_payload(
266
+ resource_types: list[type["Resource"]],
267
+ payload: dict[str, Any],
268
+ **kwargs: Any,
269
+ ) -> Optional[type]:
226
270
  """Given a resource type list and a payload, find the matching resource type."""
227
271
  if not payload or not payload.get("schemas"):
228
272
  return None
@@ -231,7 +275,9 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
231
275
  return Resource.get_by_schema(resource_types, schema, **kwargs)
232
276
 
233
277
  @field_serializer("schemas")
234
- def set_extension_schemas(self, schemas: Annotated[list[str], Required.true]):
278
+ def set_extension_schemas(
279
+ self, schemas: Annotated[list[str], Required.true]
280
+ ) -> list[str]:
235
281
  """Add model extension ids to the 'schemas' attribute."""
236
282
  extension_schemas = self.get_extension_models().keys()
237
283
  schemas = self.schemas + [
@@ -240,25 +286,95 @@ class Resource(BaseModel, Generic[AnyExtension], metaclass=ResourceMetaclass):
240
286
  return schemas
241
287
 
242
288
  @classmethod
243
- def to_schema(cls):
289
+ def to_schema(cls) -> "Schema":
244
290
  """Build a :class:`~scim2_models.Schema` from the current resource class."""
245
291
  return model_to_schema(cls)
246
292
 
247
293
  @classmethod
248
- def from_schema(cls, schema) -> "Resource":
294
+ def from_schema(cls, schema: "Schema") -> type["Resource"]:
249
295
  """Build a :class:`scim2_models.Resource` subclass from the schema definition."""
250
296
  from .schema import make_python_model
251
297
 
252
298
  return make_python_model(schema, cls)
253
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
+ kwargs["context"]["scim_attributes"] = [
309
+ validate_attribute_urn(attribute, self.__class__)
310
+ for attribute in (attributes or [])
311
+ ]
312
+ kwargs["context"]["scim_excluded_attributes"] = [
313
+ validate_attribute_urn(attribute, self.__class__)
314
+ for attribute in (excluded_attributes or [])
315
+ ]
316
+ return kwargs
317
+
318
+ def model_dump(
319
+ self,
320
+ *args: Any,
321
+ scim_ctx: Optional[Context] = Context.DEFAULT,
322
+ attributes: Optional[list[str]] = None,
323
+ excluded_attributes: Optional[list[str]] = None,
324
+ **kwargs: Any,
325
+ ) -> dict[str, Any]:
326
+ """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
327
+
328
+ :param scim_ctx: If a SCIM context is passed, some default values of
329
+ Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
330
+ messages. Pass :data:`None` to get the default Pydantic behavior.
331
+ :param attributes: A multi-valued list of strings indicating the names of resource
332
+ attributes to return in the response, overriding the set of attributes that
333
+ would be returned by default.
334
+ :param excluded_attributes: A multi-valued list of strings indicating the names of resource
335
+ attributes to be removed from the default set of attributes to return.
336
+ """
337
+ dump_kwargs = self._prepare_model_dump(
338
+ scim_ctx, attributes, excluded_attributes, **kwargs
339
+ )
340
+ if scim_ctx:
341
+ dump_kwargs.setdefault("mode", "json")
342
+ return super(ScimObject, self).model_dump(*args, **dump_kwargs)
343
+
344
+ def model_dump_json(
345
+ self,
346
+ *args: Any,
347
+ scim_ctx: Optional[Context] = Context.DEFAULT,
348
+ attributes: Optional[list[str]] = None,
349
+ excluded_attributes: Optional[list[str]] = None,
350
+ **kwargs: Any,
351
+ ) -> str:
352
+ """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
353
+
354
+ :param scim_ctx: If a SCIM context is passed, some default values of
355
+ Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
356
+ messages. Pass :data:`None` to get the default Pydantic behavior.
357
+ :param attributes: A multi-valued list of strings indicating the names of resource
358
+ attributes to return in the response, overriding the set of attributes that
359
+ would be returned by default.
360
+ :param excluded_attributes: A multi-valued list of strings indicating the names of resource
361
+ attributes to be removed from the default set of attributes to return.
362
+ """
363
+ dump_kwargs = self._prepare_model_dump(
364
+ scim_ctx, attributes, excluded_attributes, **kwargs
365
+ )
366
+ return super(ScimObject, self).model_dump_json(*args, **dump_kwargs)
367
+
254
368
 
255
369
  AnyResource = TypeVar("AnyResource", bound="Resource")
256
370
 
257
371
 
258
- def dedicated_attributes(model, excluded_models):
372
+ def dedicated_attributes(
373
+ model: type[BaseModel], excluded_models: list[type[BaseModel]]
374
+ ) -> dict[str, Any]:
259
375
  """Return attributes that are not members the parent 'excluded_models'."""
260
376
 
261
- def compare_field_infos(fi1, fi2):
377
+ def compare_field_infos(fi1: Any, fi2: Any) -> bool:
262
378
  return (
263
379
  fi1
264
380
  and fi2
@@ -281,13 +397,13 @@ def dedicated_attributes(model, excluded_models):
281
397
  return field_infos
282
398
 
283
399
 
284
- def model_to_schema(model: type[BaseModel]):
400
+ def model_to_schema(model: type[BaseModel]) -> "Schema":
285
401
  from scim2_models.rfc7643.schema import Schema
286
402
 
287
403
  schema_urn = model.model_fields["schemas"].default[0]
288
404
  field_infos = dedicated_attributes(model, [Resource])
289
405
  attributes = [
290
- model_attribute_to_attribute(model, attribute_name)
406
+ model_attribute_to_scim_attribute(model, attribute_name)
291
407
  for attribute_name in field_infos
292
408
  if attribute_name != "schemas"
293
409
  ]
@@ -300,46 +416,37 @@ def model_to_schema(model: type[BaseModel]):
300
416
  return schema
301
417
 
302
418
 
303
- def get_reference_types(type) -> list[str]:
304
- first_arg = get_args(type)[0]
305
- types = get_args(first_arg) if get_origin(first_arg) in UNION_TYPES else [first_arg]
306
-
307
- def serialize_ref_type(ref_type):
308
- if ref_type == URIReference:
309
- return "uri"
310
-
311
- elif ref_type == ExternalReference:
312
- return "external"
313
-
314
- return get_args(ref_type)[0]
315
-
316
- return list(map(serialize_ref_type, types))
317
-
318
-
319
- def model_attribute_to_attribute(model, attribute_name):
419
+ def model_attribute_to_scim_attribute(
420
+ model: type[BaseModel], attribute_name: str
421
+ ) -> "Attribute":
320
422
  from scim2_models.rfc7643.schema import Attribute
321
423
 
322
424
  field_info = model.model_fields[attribute_name]
323
425
  root_type = model.get_field_root_type(attribute_name)
426
+ if root_type is None:
427
+ raise ValueError(
428
+ f"Could not determine root type for attribute {attribute_name}"
429
+ )
324
430
  attribute_type = Attribute.Type.from_python(root_type)
325
431
  sub_attributes = (
326
432
  [
327
- model_attribute_to_attribute(root_type, sub_attribute_name)
433
+ model_attribute_to_scim_attribute(root_type, sub_attribute_name)
328
434
  for sub_attribute_name in dedicated_attributes(
329
- root_type, [MultiValuedComplexAttribute]
435
+ root_type,
436
+ [MultiValuedComplexAttribute],
330
437
  )
331
438
  if (
332
439
  attribute_name != "sub_attributes"
333
440
  or sub_attribute_name != "sub_attributes"
334
441
  )
335
442
  ]
336
- if is_complex_attribute(root_type)
443
+ if root_type and is_complex_attribute(root_type)
337
444
  else None
338
445
  )
339
446
 
340
447
  return Attribute(
341
448
  name=field_info.serialization_alias or attribute_name,
342
- type=attribute_type,
449
+ type=Attribute.Type(attribute_type),
343
450
  multi_valued=model.get_field_multiplicity(attribute_name),
344
451
  description=field_info.description,
345
452
  canonical_values=field_info.examples,
@@ -349,7 +456,7 @@ def model_attribute_to_attribute(model, attribute_name):
349
456
  returned=model.get_field_annotation(attribute_name, Returned),
350
457
  uniqueness=model.get_field_annotation(attribute_name, Uniqueness),
351
458
  sub_attributes=sub_attributes,
352
- reference_types=get_reference_types(root_type)
459
+ reference_types=Reference.get_types(root_type)
353
460
  if attribute_type == Attribute.Type.reference
354
461
  else None,
355
462
  )
@@ -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(