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
scim2_models/base.py CHANGED
@@ -1,20 +1,13 @@
1
- from collections import UserString
2
- from enum import Enum
3
- from enum import auto
4
1
  from inspect import isclass
5
- from typing import Annotated
6
2
  from typing import Any
7
- from typing import Generic
8
3
  from typing import Optional
9
- from typing import TypeVar
10
4
  from typing import get_args
11
5
  from typing import get_origin
12
6
 
13
7
  from pydantic import AliasGenerator
14
8
  from pydantic import BaseModel as PydanticBaseModel
15
9
  from pydantic import ConfigDict
16
- from pydantic import Field
17
- from pydantic import GetCoreSchemaHandler
10
+ from pydantic import FieldSerializationInfo
18
11
  from pydantic import SerializationInfo
19
12
  from pydantic import SerializerFunctionWrapHandler
20
13
  from pydantic import ValidationInfo
@@ -24,389 +17,34 @@ from pydantic import field_validator
24
17
  from pydantic import model_serializer
25
18
  from pydantic import model_validator
26
19
  from pydantic_core import PydanticCustomError
27
- from pydantic_core import core_schema
28
- from typing_extensions import NewType
29
20
  from typing_extensions import Self
30
21
 
31
- from scim2_models.utils import normalize_attribute_name
32
- from scim2_models.utils import to_camel
22
+ from scim2_models.annotations import Mutability
23
+ from scim2_models.annotations import Required
24
+ from scim2_models.annotations import Returned
25
+ from scim2_models.context import Context
26
+ from scim2_models.utils import UNION_TYPES
27
+ from scim2_models.utils import _find_field_name
28
+ from scim2_models.utils import _normalize_attribute_name
29
+ from scim2_models.utils import _to_camel
33
30
 
34
- from .utils import UNION_TYPES
35
31
 
36
- ReferenceTypes = TypeVar("ReferenceTypes")
37
- URIReference = NewType("URIReference", str)
38
- ExternalReference = NewType("ExternalReference", str)
39
-
40
-
41
- def validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
42
- """Validate that an attribute name or a sub-attribute path exist for a given model."""
43
- from scim2_models.base import BaseModel
44
-
45
- attribute_name, *sub_attribute_blocks = attribute_base.split(".")
46
- sub_attribute_base = ".".join(sub_attribute_blocks)
47
-
48
- aliases = {field.validation_alias for field in model.model_fields.values()}
49
-
50
- if normalize_attribute_name(attribute_name) not in aliases:
51
- raise ValueError(
52
- f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
53
- )
54
-
55
- if sub_attribute_base:
56
- attribute_type = model.get_field_root_type(attribute_name)
57
-
58
- if not attribute_type or not issubclass(attribute_type, BaseModel):
59
- raise ValueError(
60
- f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
61
- )
62
-
63
- validate_model_attribute(attribute_type, sub_attribute_base)
64
-
65
-
66
- def extract_schema_and_attribute_base(attribute_urn: str) -> tuple[str, str]:
67
- # Extract the schema urn part and the attribute name part from attribute
68
- # name, as defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
69
-
70
- *urn_blocks, attribute_base = attribute_urn.split(":")
71
- schema = ":".join(urn_blocks)
72
- return schema, attribute_base
73
-
74
-
75
- def validate_attribute_urn(
76
- attribute_name: str,
77
- default_resource: Optional[type["BaseModel"]] = None,
78
- resource_types: Optional[list[type["BaseModel"]]] = None,
79
- ) -> str:
80
- """Validate that an attribute urn is valid or not.
81
-
82
- :param attribute_name: The attribute urn to check.
83
- :default_resource: The default resource if `attribute_name` is not an absolute urn.
84
- :resource_types: The available resources in which to look for the attribute.
85
- :return: The normalized attribute URN.
86
- """
87
- from scim2_models.rfc7643.resource import Resource
88
-
89
- if not resource_types:
90
- resource_types = []
91
-
92
- if default_resource and default_resource not in resource_types:
93
- resource_types.append(default_resource)
94
-
95
- default_schema = (
96
- default_resource.model_fields["schemas"].default[0]
97
- if default_resource
98
- else None
99
- )
100
-
101
- schema: Optional[Any]
102
- schema, attribute_base = extract_schema_and_attribute_base(attribute_name)
103
- if not schema:
104
- schema = default_schema
105
-
106
- if not schema:
107
- raise ValueError("No default schema and relative URN")
108
-
109
- resource = Resource.get_by_schema(resource_types, schema)
110
- if not resource:
111
- raise ValueError(f"No resource matching schema '{schema}'")
112
-
113
- validate_model_attribute(resource, attribute_base)
114
-
115
- return f"{schema}:{attribute_base}"
116
-
117
-
118
- def contains_attribute_or_subattributes(attribute_urns: list[str], attribute_urn: str):
32
+ def _contains_attribute_or_subattributes(
33
+ attribute_urns: list[str], attribute_urn: str
34
+ ) -> bool:
119
35
  return attribute_urn in attribute_urns or any(
120
36
  item.startswith(f"{attribute_urn}.") or item.startswith(f"{attribute_urn}:")
121
37
  for item in attribute_urns
122
38
  )
123
39
 
124
40
 
125
- class Reference(UserString, Generic[ReferenceTypes]):
126
- """Reference type as defined in :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>`.
127
-
128
- References can take different type parameters:
129
-
130
- - Any :class:`~scim2_models.Resource` subtype, or :class:`~typing.ForwardRef` of a Resource subtype, or :data:`~typing.Union` of those,
131
- - :data:`~scim2_models.ExternalReference`
132
- - :data:`~scim2_models.URIReference`
133
-
134
- Examples
135
- --------
136
-
137
- .. code-block:: python
138
-
139
- class Foobar(Resource):
140
- bff: Reference[User]
141
- managers: Reference[Union["User", "Group"]]
142
- photo: Reference[ExternalReference]
143
- website: Reference[URIReference]
144
-
145
- """
146
-
147
- @classmethod
148
- def __get_pydantic_core_schema__(
149
- cls,
150
- _source: type[Any],
151
- _handler: GetCoreSchemaHandler,
152
- ) -> core_schema.CoreSchema:
153
- return core_schema.no_info_after_validator_function(
154
- cls._validate, core_schema.str_schema()
155
- )
156
-
157
- @classmethod
158
- def _validate(cls, input_value: str, /) -> str:
159
- return input_value
160
-
161
-
162
- class Context(Enum):
163
- """Represent the different HTTP contexts detailed in :rfc:`RFC7644 §3.2 <7644#section-3.2>`.
164
-
165
- Contexts are intended to be used during model validation and serialization.
166
- For instance a client preparing a resource creation POST request can use
167
- :code:`resource.model_dump(Context.RESOURCE_CREATION_REQUEST)` and
168
- the server can then validate it with
169
- :code:`resource.model_validate(Context.RESOURCE_CREATION_REQUEST)`.
170
- """
171
-
172
- DEFAULT = auto()
173
- """The default context.
174
-
175
- All fields are accepted during validation, and all fields are
176
- serialized during a dump.
177
- """
178
-
179
- RESOURCE_CREATION_REQUEST = auto()
180
- """The resource creation request context.
181
-
182
- Should be used for clients building a payload for a resource creation request,
183
- and servers validating resource creation request payloads.
184
-
185
- - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
186
- - When used for validation, it will raise a :class:`~pydantic.ValidationError`:
187
- - when finding attributes annotated with :attr:`~scim2_models.Mutability.read_only`,
188
- - when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing on null.
189
- """
190
-
191
- RESOURCE_CREATION_RESPONSE = auto()
192
- """The resource creation response context.
193
-
194
- Should be used for servers building a payload for a resource
195
- creation response, and clients validating resource creation response
196
- payloads.
197
-
198
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`;
199
- - When used for serialization, it will:
200
- - always dump attributes annotated with :attr:`~scim2_models.Returned.always`;
201
- - never dump attributes annotated with :attr:`~scim2_models.Returned.never`;
202
- - dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded;
203
- - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
204
- """
205
-
206
- RESOURCE_QUERY_REQUEST = auto()
207
- """The resource query request context.
208
-
209
- Should be used for clients building a payload for a resource query request,
210
- and servers validating resource query request payloads.
211
-
212
- - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.write_only`.
213
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Mutability.write_only`.
214
- """
215
-
216
- RESOURCE_QUERY_RESPONSE = auto()
217
- """The resource query response context.
218
-
219
- Should be used for servers building a payload for a resource query
220
- response, and clients validating resource query response payloads.
221
-
222
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`;
223
- - When used for serialization, it will:
224
- - always dump attributes annotated with :attr:`~scim2_models.Returned.always`;
225
- - never dump attributes annotated with :attr:`~scim2_models.Returned.never`;
226
- - dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded;
227
- - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
228
- """
229
-
230
- RESOURCE_REPLACEMENT_REQUEST = auto()
231
- """The resource replacement request context.
232
-
233
- Should be used for clients building a payload for a resource replacement request,
234
- and servers validating resource replacement request payloads.
235
-
236
- - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
237
- - When used for validation, it will ignore attributes annotated with :attr:`scim2_models.Mutability.read_only` and raise a :class:`~pydantic.ValidationError`:
238
- - when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable` different than :paramref:`~scim2_models.BaseModel.model_validate.original`:
239
- - when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing on null.
240
- """
241
-
242
- RESOURCE_REPLACEMENT_RESPONSE = auto()
243
- """The resource replacement response context.
244
-
245
- Should be used for servers building a payload for a resource
246
- replacement response, and clients validating resource query
247
- replacement payloads.
248
-
249
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`;
250
- - When used for serialization, it will:
251
- - always dump attributes annotated with :attr:`~scim2_models.Returned.always`;
252
- - never dump attributes annotated with :attr:`~scim2_models.Returned.never`;
253
- - dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded;
254
- - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
255
- """
256
-
257
- SEARCH_REQUEST = auto()
258
- """The search request context.
259
-
260
- Should be used for clients building a payload for a search request,
261
- and servers validating search request payloads.
262
-
263
- - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.write_only`.
264
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Mutability.write_only`.
265
- """
266
-
267
- SEARCH_RESPONSE = auto()
268
- """The resource query response context.
269
-
270
- Should be used for servers building a payload for a search response,
271
- and clients validating resource search payloads.
272
-
273
- - When used for validation, it will raise a :class:`~pydantic.ValidationError` when finding attributes annotated with :attr:`~scim2_models.Returned.never` or when attributes annotated with :attr:`~scim2_models.Returned.always` are missing or :data:`None`;
274
- - When used for serialization, it will:
275
- - always dump attributes annotated with :attr:`~scim2_models.Returned.always`;
276
- - never dump attributes annotated with :attr:`~scim2_models.Returned.never`;
277
- - dump attributes annotated with :attr:`~scim2_models.Returned.default` unless they are explicitly excluded;
278
- - not dump attributes annotated with :attr:`~scim2_models.Returned.request` unless they are explicitly included.
279
- """
280
-
281
- @classmethod
282
- def is_request(cls, ctx: "Context") -> bool:
283
- return ctx in (
284
- cls.RESOURCE_CREATION_REQUEST,
285
- cls.RESOURCE_QUERY_REQUEST,
286
- cls.RESOURCE_REPLACEMENT_REQUEST,
287
- cls.SEARCH_REQUEST,
288
- )
289
-
290
- @classmethod
291
- def is_response(cls, ctx: "Context") -> bool:
292
- return ctx in (
293
- cls.RESOURCE_CREATION_RESPONSE,
294
- cls.RESOURCE_QUERY_RESPONSE,
295
- cls.RESOURCE_REPLACEMENT_RESPONSE,
296
- cls.SEARCH_RESPONSE,
297
- )
298
-
299
-
300
- class Mutability(str, Enum):
301
- """A single keyword indicating the circumstances under which the value of the attribute can be (re)defined."""
302
-
303
- read_only = "readOnly"
304
- """The attribute SHALL NOT be modified."""
305
-
306
- read_write = "readWrite"
307
- """The attribute MAY be updated and read at any time."""
308
-
309
- immutable = "immutable"
310
- """The attribute MAY be defined at resource creation (e.g., POST) or at
311
- record replacement via a request (e.g., a PUT).
312
-
313
- The attribute SHALL NOT be updated.
314
- """
315
-
316
- write_only = "writeOnly"
317
- """The attribute MAY be updated at any time.
318
-
319
- Attribute values SHALL NOT be returned (e.g., because the value is a
320
- stored hash). Note: An attribute with a mutability of "writeOnly"
321
- usually also has a returned setting of "never".
322
- """
323
-
324
- _default = read_write
325
-
326
-
327
- class Returned(str, Enum):
328
- """A single keyword that indicates when an attribute and associated values are returned in response to a GET request or in response to a PUT, POST, or PATCH request."""
329
-
330
- always = "always" # cannot be excluded
331
- """The attribute is always returned, regardless of the contents of the
332
- "attributes" parameter.
333
-
334
- For example, "id" is always returned to identify a SCIM resource.
335
- """
336
-
337
- never = "never" # always excluded
338
- """The attribute is never returned, regardless of the contents of the
339
- "attributes" parameter."""
340
-
341
- default = "default" # included by default but can be excluded
342
- """The attribute is returned by default in all SCIM operation responses
343
- where attribute values are returned, unless it is explicitly excluded."""
344
-
345
- request = "request" # excluded by default but can be included
346
- """The attribute is returned in response to any PUT, POST, or PATCH
347
- operations if specified in the "attributes" parameter."""
348
-
349
- _default = default
350
-
351
-
352
- class Uniqueness(str, Enum):
353
- """A single keyword value that specifies how the service provider enforces uniqueness of attribute values."""
354
-
355
- none = "none"
356
- """The values are not intended to be unique in any way."""
357
-
358
- server = "server"
359
- """The value SHOULD be unique within the context of the current SCIM
360
- endpoint (or tenancy) and MAY be globally unique (e.g., a "username", email
361
- address, or other server-generated key or counter).
362
-
363
- No two resources on the same server SHOULD possess the same value.
364
- """
365
-
366
- global_ = "global"
367
- """The value SHOULD be globally unique (e.g., an email address, a GUID, or
368
- other value).
369
-
370
- No two resources on any server SHOULD possess the same value.
371
- """
372
-
373
- _default = none
374
-
375
-
376
- class Required(Enum):
377
- """A Boolean value that specifies whether the attribute is required or not.
378
-
379
- Missing required attributes raise a :class:`~pydantic.ValidationError` on :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST` and :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` validations.
380
- """
381
-
382
- true = True
383
- false = False
384
-
385
- _default = false
386
-
387
- def __bool__(self):
388
- return self.value
389
-
390
-
391
- class CaseExact(Enum):
392
- """A Boolean value that specifies whether a string attribute is case- sensitive or not."""
393
-
394
- true = True
395
- false = False
396
-
397
- _default = false
398
-
399
- def __bool__(self):
400
- return self.value
401
-
402
-
403
41
  class BaseModel(PydanticBaseModel):
404
42
  """Base Model for everything."""
405
43
 
406
44
  model_config = ConfigDict(
407
45
  alias_generator=AliasGenerator(
408
- validation_alias=normalize_attribute_name,
409
- serialization_alias=to_camel,
46
+ validation_alias=_normalize_attribute_name,
47
+ serialization_alias=_to_camel,
410
48
  ),
411
49
  validate_assignment=True,
412
50
  populate_by_name=True,
@@ -416,12 +54,40 @@ class BaseModel(PydanticBaseModel):
416
54
 
417
55
  @classmethod
418
56
  def get_field_annotation(cls, field_name: str, annotation_type: type) -> Any:
419
- """Return the annotation of type 'annotation_type' of the field 'field_name'."""
57
+ """Return the annotation of type 'annotation_type' of the field 'field_name'.
58
+
59
+ This method extracts SCIM-specific annotations from a field's metadata,
60
+ such as :class:`~scim2_models.Mutability`, :class:`~scim2_models.Required`,
61
+ or :class:`~scim2_models.Returned` annotations.
62
+
63
+ :return: The annotation instance if found, otherwise the annotation type's default value
64
+
65
+ >>> from scim2_models.resources.user import User
66
+ >>> from scim2_models.annotations import Mutability, Required
67
+
68
+ Get the mutability annotation of the 'id' field:
69
+
70
+ >>> mutability = User.get_field_annotation("id", Mutability)
71
+ >>> mutability
72
+ <Mutability.read_only: 'readOnly'>
73
+
74
+ Get the required annotation of the 'user_name' field:
75
+
76
+ >>> required = User.get_field_annotation("user_name", Required)
77
+ >>> required
78
+ <Required.true: True>
79
+
80
+ If no annotation is found, returns the default value:
81
+
82
+ >>> missing = User.get_field_annotation("display_name", Required)
83
+ >>> missing
84
+ <Required.false: False>
85
+ """
420
86
  field_metadata = cls.model_fields[field_name].metadata
421
87
 
422
88
  default_value = getattr(annotation_type, "_default", None)
423
89
 
424
- def annotation_type_filter(item):
90
+ def annotation_type_filter(item: Any) -> bool:
425
91
  return isinstance(item, annotation_type)
426
92
 
427
93
  field_annotation = next(
@@ -433,8 +99,34 @@ class BaseModel(PydanticBaseModel):
433
99
  def get_field_root_type(cls, attribute_name: str) -> Optional[type]:
434
100
  """Extract the root type from a model field.
435
101
 
436
- For example, return 'GroupMember' for
437
- 'Optional[List[GroupMember]]'
102
+ This method unwraps complex type annotations to find the underlying
103
+ type, removing Optional and List wrappers to get to the actual type
104
+ of the field's content.
105
+
106
+ :return: The root type of the field, or None if not found
107
+
108
+ >>> from scim2_models.resources.user import User
109
+ >>> from scim2_models.resources.group import Group
110
+
111
+ Simple type:
112
+
113
+ >>> User.get_field_root_type("user_name")
114
+ <class 'str'>
115
+
116
+ ``Optional`` type unwraps to the underlying type:
117
+
118
+ >>> User.get_field_root_type("display_name")
119
+ <class 'str'>
120
+
121
+ ``List`` type unwraps to the element type:
122
+
123
+ >>> User.get_field_root_type("emails") # doctest: +ELLIPSIS
124
+ <class 'scim2_models.resources.user.Email'>
125
+
126
+ ``Optional[List[T]]`` unwraps to ``T``:
127
+
128
+ >>> Group.get_field_root_type("members") # doctest: +ELLIPSIS
129
+ <class 'scim2_models.resources.group.GroupMember'>
438
130
  """
439
131
  attribute_type = cls.model_fields[attribute_name].annotation
440
132
 
@@ -451,7 +143,20 @@ class BaseModel(PydanticBaseModel):
451
143
 
452
144
  @classmethod
453
145
  def get_field_multiplicity(cls, attribute_name: str) -> bool:
454
- """Indicate whether a field holds multiple values."""
146
+ """Indicate whether a field holds multiple values.
147
+
148
+ This method determines if a field is defined as a list type,
149
+ which indicates it can contain multiple values. It handles
150
+ Optional wrappers correctly.
151
+
152
+ :return: True if the field holds multiple values (is a list), False otherwise
153
+
154
+ >>> from scim2_models.resources.user import User
155
+ >>> User.get_field_multiplicity("user_name")
156
+ False
157
+ >>> User.get_field_multiplicity("emails")
158
+ True
159
+ """
455
160
  attribute_type = cls.model_fields[attribute_name].annotation
456
161
 
457
162
  # extract 'x' from 'Optional[x]'
@@ -466,9 +171,10 @@ class BaseModel(PydanticBaseModel):
466
171
  def check_request_attributes_mutability(
467
172
  cls, value: Any, info: ValidationInfo
468
173
  ) -> Any:
469
- """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7653#section-7>`."""
174
+ """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`."""
470
175
  if (
471
176
  not info.context
177
+ or not info.field_name
472
178
  or not info.context.get("scim")
473
179
  or not Context.is_request(info.context["scim"])
474
180
  ):
@@ -508,41 +214,73 @@ class BaseModel(PydanticBaseModel):
508
214
  ) -> Self:
509
215
  """Normalize payload attribute names.
510
216
 
511
- :rfc:`RFC7643 §2.1 <7653#section-2.1>` indicate that attribute
217
+ :rfc:`RFC7643 §2.1 <7643#section-2.1>` indicate that attribute
512
218
  names should be case-insensitive. Any attribute name is
513
219
  transformed in lowercase so any case is handled the same way.
514
220
  """
515
221
 
516
- def normalize_value(value: Any) -> Any:
517
- if isinstance(value, dict):
518
- return {
519
- normalize_attribute_name(k): normalize_value(v)
520
- for k, v in value.items()
521
- }
522
- return value
222
+ def normalize_dict_keys(
223
+ input_dict: dict, model_class: type["BaseModel"]
224
+ ) -> dict:
225
+ """Normalize dictionary keys, preserving case for Any fields."""
226
+ result = {}
523
227
 
524
- normalized_value = normalize_value(value)
525
- return handler(normalized_value)
228
+ for key, val in input_dict.items():
229
+ field_name = _find_field_name(model_class, key)
230
+ field_type = (
231
+ model_class.get_field_root_type(field_name) if field_name else None
232
+ )
233
+
234
+ # Don't normalize keys for attributes typed with Any
235
+ # This way, agnostic dicts such as PatchOp.operations.value
236
+ # are preserved
237
+ if field_name and field_type == Any:
238
+ result[key] = normalize_value(val)
239
+ else:
240
+ result[_normalize_attribute_name(key)] = normalize_value(
241
+ val, field_type
242
+ )
243
+
244
+ return result
245
+
246
+ def normalize_value(
247
+ val: Any, model_class: Optional[type["BaseModel"]] = None
248
+ ) -> Any:
249
+ """Normalize input value based on model class."""
250
+ if not isinstance(val, dict):
251
+ return val
252
+
253
+ # If no model_class, preserve original keys
254
+ if not model_class:
255
+ return {k: normalize_value(v) for k, v in val.items()}
256
+
257
+ return normalize_dict_keys(val, model_class)
258
+
259
+ normalized_value = normalize_value(value, cls)
260
+ obj = handler(normalized_value)
261
+ assert isinstance(obj, cls)
262
+ return obj
526
263
 
527
264
  @model_validator(mode="wrap")
528
265
  @classmethod
529
266
  def check_response_attributes_returnability(
530
267
  cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
531
268
  ) -> Self:
532
- """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7653#section-7>`."""
533
- value = handler(value)
269
+ """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`."""
270
+ obj = handler(value)
271
+ assert isinstance(obj, cls)
534
272
 
535
273
  if (
536
274
  not info.context
537
275
  or not info.context.get("scim")
538
276
  or not Context.is_response(info.context["scim"])
539
277
  ):
540
- return value
278
+ return obj
541
279
 
542
280
  for field_name in cls.model_fields:
543
281
  returnability = cls.get_field_annotation(field_name, Returned)
544
282
 
545
- if returnability == Returned.always and getattr(value, field_name) is None:
283
+ if returnability == Returned.always and getattr(obj, field_name) is None:
546
284
  raise PydanticCustomError(
547
285
  "returned_error",
548
286
  "Field '{field_name}' has returnability 'always' but value is missing or null",
@@ -551,10 +289,7 @@ class BaseModel(PydanticBaseModel):
551
289
  },
552
290
  )
553
291
 
554
- if (
555
- returnability == Returned.never
556
- and getattr(value, field_name) is not None
557
- ):
292
+ if returnability == Returned.never and getattr(obj, field_name) is not None:
558
293
  raise PydanticCustomError(
559
294
  "returned_error",
560
295
  "Field '{field_name}' has returnability 'never' but value is set",
@@ -563,7 +298,7 @@ class BaseModel(PydanticBaseModel):
563
298
  },
564
299
  )
565
300
 
566
- return value
301
+ return obj
567
302
 
568
303
  @model_validator(mode="wrap")
569
304
  @classmethod
@@ -571,7 +306,8 @@ class BaseModel(PydanticBaseModel):
571
306
  cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
572
307
  ) -> Self:
573
308
  """Check that the required attributes are present in creations and replacement requests."""
574
- value = handler(value)
309
+ obj = handler(value)
310
+ assert isinstance(obj, cls)
575
311
 
576
312
  if (
577
313
  not info.context
@@ -582,12 +318,12 @@ class BaseModel(PydanticBaseModel):
582
318
  Context.RESOURCE_REPLACEMENT_REQUEST,
583
319
  )
584
320
  ):
585
- return value
321
+ return obj
586
322
 
587
323
  for field_name in cls.model_fields:
588
324
  necessity = cls.get_field_annotation(field_name, Required)
589
325
 
590
- if necessity == Required.true and getattr(value, field_name) is None:
326
+ if necessity == Required.true and getattr(obj, field_name) is None:
591
327
  raise PydanticCustomError(
592
328
  "required_error",
593
329
  "Field '{field_name}' is required but value is missing or null",
@@ -596,7 +332,7 @@ class BaseModel(PydanticBaseModel):
596
332
  },
597
333
  )
598
334
 
599
- return value
335
+ return obj
600
336
 
601
337
  @model_validator(mode="wrap")
602
338
  @classmethod
@@ -604,9 +340,10 @@ class BaseModel(PydanticBaseModel):
604
340
  cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
605
341
  ) -> Self:
606
342
  """Check if 'immutable' attributes have been mutated in replacement requests."""
607
- from scim2_models.rfc7643.resource import Resource
343
+ from scim2_models.resources.resource import Resource
608
344
 
609
- value = handler(value)
345
+ obj = handler(value)
346
+ assert isinstance(obj, cls)
610
347
 
611
348
  context = info.context.get("scim") if info.context else None
612
349
  original = info.context.get("original") if info.context else None
@@ -615,12 +352,16 @@ class BaseModel(PydanticBaseModel):
615
352
  and issubclass(cls, Resource)
616
353
  and original is not None
617
354
  ):
618
- cls.check_mutability_issues(original, value)
619
- return value
355
+ cls._check_mutability_issues(original, obj)
356
+ return obj
620
357
 
621
358
  @classmethod
622
- def check_mutability_issues(cls, original: "BaseModel", replacement: "BaseModel"):
359
+ def _check_mutability_issues(
360
+ cls, original: "BaseModel", replacement: "BaseModel"
361
+ ) -> None:
623
362
  """Compare two instances, and check for differences of values on the fields marked as immutable."""
363
+ from .attributes import is_complex_attribute
364
+
624
365
  model = replacement.__class__
625
366
  for field_name in model.model_fields:
626
367
  mutability = model.get_field_annotation(field_name, Mutability)
@@ -634,61 +375,65 @@ class BaseModel(PydanticBaseModel):
634
375
  )
635
376
 
636
377
  attr_type = model.get_field_root_type(field_name)
637
- if is_complex_attribute(attr_type) and not model.get_field_multiplicity(
638
- field_name
378
+ if (
379
+ attr_type
380
+ and is_complex_attribute(attr_type)
381
+ and not model.get_field_multiplicity(field_name)
639
382
  ):
640
383
  original_val = getattr(original, field_name)
641
384
  replacement_value = getattr(replacement, field_name)
642
385
  if original_val is not None and replacement_value is not None:
643
- cls.check_mutability_issues(original_val, replacement_value)
386
+ cls._check_mutability_issues(original_val, replacement_value)
644
387
 
645
- def mark_with_schema(self):
646
- """Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_schema' attribute.
388
+ def _set_complex_attribute_urns(self) -> None:
389
+ """Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute.
647
390
 
648
- '_schema' will later be used by 'get_attribute_urn'.
391
+ '_attribute_urn' will later be used by 'get_attribute_urn'.
649
392
  """
650
- from scim2_models.rfc7643.resource import Resource
393
+ from .attributes import ComplexAttribute
394
+ from .attributes import is_complex_attribute
395
+
396
+ if isinstance(self, ComplexAttribute):
397
+ main_schema = self._attribute_urn
398
+ separator = "."
399
+ else:
400
+ main_schema = self.__class__.model_fields["schemas"].default[0]
401
+ separator = ":"
651
402
 
652
403
  for field_name in self.__class__.model_fields:
653
404
  attr_type = self.get_field_root_type(field_name)
654
- if not is_complex_attribute(attr_type):
405
+ if not attr_type or not is_complex_attribute(attr_type):
655
406
  continue
656
407
 
657
- main_schema = (
658
- getattr(self, "_schema", None)
659
- or self.__class__.model_fields["schemas"].default[0]
660
- )
661
-
662
- separator = ":" if isinstance(self, Resource) else "."
663
408
  schema = f"{main_schema}{separator}{field_name}"
664
409
 
665
410
  if attr_value := getattr(self, field_name):
666
411
  if isinstance(attr_value, list):
667
412
  for item in attr_value:
668
- item._schema = schema
413
+ item._attribute_urn = schema
669
414
  else:
670
- attr_value._schema = schema
415
+ attr_value._attribute_urn = schema
671
416
 
672
417
  @field_serializer("*", mode="wrap")
673
418
  def scim_serializer(
674
419
  self,
675
420
  value: Any,
676
421
  handler: SerializerFunctionWrapHandler,
677
- info: SerializationInfo,
422
+ info: FieldSerializationInfo,
678
423
  ) -> Any:
679
424
  """Serialize the fields according to mutability indications passed in the serialization context."""
680
425
  value = handler(value)
681
426
  scim_ctx = info.context.get("scim") if info.context else None
682
427
 
683
428
  if scim_ctx and Context.is_request(scim_ctx):
684
- value = self.scim_request_serializer(value, info)
429
+ value = self._scim_request_serializer(value, info)
685
430
 
686
431
  if scim_ctx and Context.is_response(scim_ctx):
687
- value = self.scim_response_serializer(value, info)
432
+ value = self._scim_response_serializer(value, info)
688
433
 
689
434
  return value
690
435
 
691
- def scim_request_serializer(self, value: Any, info: SerializationInfo) -> Any:
436
+ def _scim_request_serializer(self, value: Any, info: FieldSerializationInfo) -> Any:
692
437
  """Serialize the fields according to mutability indications passed in the serialization context."""
693
438
  mutability = self.get_field_annotation(info.field_name, Mutability)
694
439
  scim_ctx = info.context.get("scim") if info.context else None
@@ -712,7 +457,9 @@ class BaseModel(PydanticBaseModel):
712
457
 
713
458
  return value
714
459
 
715
- def scim_response_serializer(self, value: Any, info: SerializationInfo) -> Any:
460
+ def _scim_response_serializer(
461
+ self, value: Any, info: FieldSerializationInfo
462
+ ) -> Any:
716
463
  """Serialize the fields according to returnability indications passed in the serialization context."""
717
464
  returnability = self.get_field_annotation(info.field_name, Returned)
718
465
  attribute_urn = self.get_attribute_urn(info.field_name)
@@ -721,9 +468,9 @@ class BaseModel(PydanticBaseModel):
721
468
  info.context.get("scim_excluded_attributes", []) if info.context else []
722
469
  )
723
470
 
724
- attribute_urn = normalize_attribute_name(attribute_urn)
725
- included_urns = [normalize_attribute_name(urn) for urn in included_urns]
726
- excluded_urns = [normalize_attribute_name(urn) for urn in excluded_urns]
471
+ attribute_urn = _normalize_attribute_name(attribute_urn)
472
+ included_urns = [_normalize_attribute_name(urn) for urn in included_urns]
473
+ excluded_urns = [_normalize_attribute_name(urn) for urn in excluded_urns]
727
474
 
728
475
  if returnability == Returned.never:
729
476
  return None
@@ -731,7 +478,7 @@ class BaseModel(PydanticBaseModel):
731
478
  if returnability == Returned.default and (
732
479
  (
733
480
  included_urns
734
- and not contains_attribute_or_subattributes(
481
+ and not _contains_attribute_or_subattributes(
735
482
  included_urns, attribute_urn
736
483
  )
737
484
  )
@@ -746,20 +493,20 @@ class BaseModel(PydanticBaseModel):
746
493
 
747
494
  @model_serializer(mode="wrap")
748
495
  def model_serializer_exclude_none(
749
- self, handler, info: SerializationInfo
496
+ self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
750
497
  ) -> dict[str, Any]:
751
498
  """Remove `None` values inserted by the :meth:`~scim2_models.base.BaseModel.scim_serializer`."""
752
- self.mark_with_schema()
499
+ self._set_complex_attribute_urns()
753
500
  result = handler(self)
754
501
  return {key: value for key, value in result.items() if value is not None}
755
502
 
756
503
  @classmethod
757
504
  def model_validate(
758
505
  cls,
759
- *args,
506
+ *args: Any,
760
507
  scim_ctx: Optional[Context] = Context.DEFAULT,
761
508
  original: Optional["BaseModel"] = None,
762
- **kwargs,
509
+ **kwargs: Any,
763
510
  ) -> Self:
764
511
  """Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
765
512
 
@@ -779,126 +526,20 @@ class BaseModel(PydanticBaseModel):
779
526
 
780
527
  return super().model_validate(*args, **kwargs)
781
528
 
782
- def _prepare_model_dump(
783
- self,
784
- scim_ctx: Optional[Context] = Context.DEFAULT,
785
- attributes: Optional[list[str]] = None,
786
- excluded_attributes: Optional[list[str]] = None,
787
- **kwargs,
788
- ):
789
- kwargs.setdefault("context", {}).setdefault("scim", scim_ctx)
790
- kwargs["context"]["scim_attributes"] = [
791
- validate_attribute_urn(attribute, self.__class__)
792
- for attribute in (attributes or [])
793
- ]
794
- kwargs["context"]["scim_excluded_attributes"] = [
795
- validate_attribute_urn(attribute, self.__class__)
796
- for attribute in (excluded_attributes or [])
797
- ]
798
-
799
- if scim_ctx:
800
- kwargs.setdefault("exclude_none", True)
801
- kwargs.setdefault("by_alias", True)
802
-
803
- return kwargs
804
-
805
- def model_dump(
806
- self,
807
- *args,
808
- scim_ctx: Optional[Context] = Context.DEFAULT,
809
- attributes: Optional[list[str]] = None,
810
- excluded_attributes: Optional[list[str]] = None,
811
- **kwargs,
812
- ) -> dict:
813
- """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
814
-
815
- :param scim_ctx: If a SCIM context is passed, some default values of
816
- Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
817
- messages. Pass :data:`None` to get the default Pydantic behavior.
818
- """
819
- dump_kwargs = self._prepare_model_dump(
820
- scim_ctx, attributes, excluded_attributes, **kwargs
821
- )
822
- if scim_ctx:
823
- dump_kwargs.setdefault("mode", "json")
824
- return super().model_dump(*args, **dump_kwargs)
825
-
826
- def model_dump_json(
827
- self,
828
- *args,
829
- scim_ctx: Optional[Context] = Context.DEFAULT,
830
- attributes: Optional[list[str]] = None,
831
- excluded_attributes: Optional[list[str]] = None,
832
- **kwargs,
833
- ) -> dict:
834
- """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
835
-
836
- :param scim_ctx: If a SCIM context is passed, some default values of
837
- Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
838
- messages. Pass :data:`None` to get the default Pydantic behavior.
839
- """
840
- dump_kwargs = self._prepare_model_dump(
841
- scim_ctx, attributes, excluded_attributes, **kwargs
842
- )
843
- return super().model_dump_json(*args, **dump_kwargs)
844
-
845
529
  def get_attribute_urn(self, field_name: str) -> str:
846
530
  """Build the full URN of the attribute.
847
531
 
848
532
  See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
849
533
  """
534
+ from scim2_models.resources.resource import Extension
535
+
850
536
  main_schema = self.__class__.model_fields["schemas"].default[0]
851
- alias = (
852
- self.__class__.model_fields[field_name].serialization_alias or field_name
537
+ field = self.__class__.model_fields[field_name]
538
+ alias = field.serialization_alias or field_name
539
+ field_type = self.get_field_root_type(field_name)
540
+ full_urn = (
541
+ alias
542
+ if isclass(field_type) and issubclass(field_type, Extension)
543
+ else f"{main_schema}:{alias}"
853
544
  )
854
-
855
- # if alias contains a ':' this is an extension urn
856
- full_urn = alias if ":" in alias else f"{main_schema}:{alias}"
857
545
  return full_urn
858
-
859
-
860
- class ComplexAttribute(BaseModel):
861
- """A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""
862
-
863
- _schema: Optional[str] = None
864
-
865
- def get_attribute_urn(self, field_name: str) -> str:
866
- """Build the full URN of the attribute.
867
-
868
- See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
869
- """
870
- alias = (
871
- self.__class__.model_fields[field_name].serialization_alias or field_name
872
- )
873
- return f"{self._schema}.{alias}"
874
-
875
-
876
- class MultiValuedComplexAttribute(ComplexAttribute):
877
- type: Optional[str] = None
878
- """A label indicating the attribute's function."""
879
-
880
- primary: Optional[bool] = None
881
- """A Boolean value indicating the 'primary' or preferred attribute value
882
- for this attribute."""
883
-
884
- display: Annotated[Optional[str], Mutability.immutable] = None
885
- """A human-readable name, primarily used for display purposes."""
886
-
887
- value: Optional[Any] = None
888
- """The value of an entitlement."""
889
-
890
- ref: Optional[Reference] = Field(None, serialization_alias="$ref")
891
- """The reference URI of a target resource, if the attribute is a
892
- reference."""
893
-
894
-
895
- def is_complex_attribute(type) -> bool:
896
- # issubclass raise a TypeError with 'Reference' on python < 3.11
897
- return (
898
- get_origin(type) != Reference
899
- and isclass(type)
900
- and issubclass(type, (ComplexAttribute, MultiValuedComplexAttribute))
901
- )
902
-
903
-
904
- BaseModelType: type = type(BaseModel)