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
@@ -0,0 +1,478 @@
1
+ from enum import Enum
2
+ from inspect import isclass
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
+
9
+ from pydantic import Field
10
+ from pydantic import ValidationInfo
11
+ from pydantic import field_validator
12
+ from pydantic import model_validator
13
+ from typing_extensions import Self
14
+
15
+ from ..annotations import Mutability
16
+ from ..annotations import Required
17
+ from ..attributes import ComplexAttribute
18
+ from ..base import BaseModel
19
+ from ..context import Context
20
+ from ..resources.resource import Resource
21
+ from ..urn import _resolve_path_to_target
22
+ from ..utils import _extract_field_name
23
+ from ..utils import _find_field_name
24
+ from ..utils import _validate_scim_path_syntax
25
+ from .error import Error
26
+ from .message import Message
27
+ from .message import _get_resource_class
28
+
29
+ T = TypeVar("T", bound=Resource)
30
+
31
+
32
+ class PatchOperation(ComplexAttribute):
33
+ class Op(str, Enum):
34
+ replace_ = "replace"
35
+ remove = "remove"
36
+ add = "add"
37
+
38
+ op: Op
39
+ """Each PATCH operation object MUST have exactly one "op" member, whose
40
+ value indicates the operation to perform and MAY be one of "add", "remove",
41
+ or "replace".
42
+
43
+ .. note::
44
+
45
+ For the sake of compatibility with Microsoft Entra,
46
+ despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive.
47
+ """
48
+
49
+ path: Optional[str] = None
50
+ """The "path" attribute value is a String containing an attribute path
51
+ describing the target of the operation."""
52
+
53
+ def _validate_mutability(
54
+ self, resource_class: type[BaseModel], field_name: str
55
+ ) -> None:
56
+ """Validate mutability constraints."""
57
+ # RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
58
+ if field_name not in resource_class.model_fields:
59
+ return
60
+
61
+ mutability = resource_class.get_field_annotation(field_name, Mutability)
62
+
63
+ # RFC 7643 Section 7: "Attributes with mutability 'readOnly' SHALL NOT be modified"
64
+ if mutability == Mutability.read_only and self.op in (
65
+ PatchOperation.Op.add,
66
+ PatchOperation.Op.replace_,
67
+ ):
68
+ raise ValueError(Error.make_mutability_error().detail)
69
+
70
+ # RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated"
71
+ if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_:
72
+ raise ValueError(Error.make_mutability_error().detail)
73
+
74
+ def _validate_required_attribute(
75
+ self, resource_class: type[BaseModel], field_name: str
76
+ ) -> None:
77
+ """Validate required attribute constraints for remove operations."""
78
+ # RFC 7644 Section 3.5.2.3: Only validate for remove operations
79
+ if self.op != PatchOperation.Op.remove:
80
+ return
81
+
82
+ # RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
83
+ if field_name not in resource_class.model_fields:
84
+ return
85
+
86
+ required = resource_class.get_field_annotation(field_name, Required)
87
+
88
+ # RFC 7643 Section 7: "Required attributes SHALL NOT be removed"
89
+ if required == Required.true:
90
+ raise ValueError(Error.make_invalid_value_error().detail)
91
+
92
+ @model_validator(mode="after")
93
+ def validate_operation_requirements(self, info: ValidationInfo) -> Self:
94
+ """Validate operation requirements according to RFC 7644."""
95
+ # Only validate in PATCH request context
96
+ scim_ctx = info.context.get("scim") if info.context else None
97
+ if scim_ctx != Context.RESOURCE_PATCH_REQUEST:
98
+ return self
99
+
100
+ # RFC 7644 Section 3.5.2: "Path syntax validation according to ABNF grammar"
101
+ if self.path is not None and not _validate_scim_path_syntax(self.path):
102
+ raise ValueError(Error.make_invalid_path_error().detail)
103
+
104
+ # RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
105
+ if self.path is None and self.op == PatchOperation.Op.remove:
106
+ raise ValueError(Error.make_invalid_path_error().detail)
107
+
108
+ # RFC 7644 Section 3.5.2.1: "Value is required for add operations"
109
+ if self.op == PatchOperation.Op.add and self.value is None:
110
+ raise ValueError(Error.make_invalid_value_error().detail)
111
+
112
+ return self
113
+
114
+ value: Optional[Any] = None
115
+
116
+ @field_validator("op", mode="before")
117
+ @classmethod
118
+ def normalize_op(cls, v: Any) -> Any:
119
+ """Ignore case for op.
120
+
121
+ This brings
122
+ `compatibility with Microsoft Entra <https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups#general>`_:
123
+
124
+ Don't require a case-sensitive match on structural elements in SCIM,
125
+ in particular PATCH op operation values, as defined in section 3.5.2.
126
+ Microsoft Entra ID emits the values of op as Add, Replace, and Remove.
127
+ """
128
+ if isinstance(v, str):
129
+ return v.lower()
130
+ return v
131
+
132
+
133
+ class PatchOp(Message, Generic[T]):
134
+ """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
135
+
136
+ Type parameter T is required and must be a concrete Resource subclass.
137
+ Usage: PatchOp[User], PatchOp[Group], etc.
138
+
139
+ .. note::
140
+ - Always use with a specific type parameter, e.g., PatchOp[User]
141
+ - PatchOp[Resource] is not allowed - use a concrete subclass instead
142
+ - Union types are not supported - use a specific resource type
143
+ - Using PatchOp without a type parameter raises TypeError
144
+ """
145
+
146
+ def __new__(cls, *args, **kwargs):
147
+ """Create new PatchOp instance with type parameter validation.
148
+
149
+ Only handles the case of direct instantiation without type parameter (PatchOp()).
150
+ All type parameter validation is handled by __class_getitem__.
151
+ """
152
+ if (
153
+ cls.__name__ == "PatchOp"
154
+ and not hasattr(cls, "__origin__")
155
+ and not hasattr(cls, "__args__")
156
+ ):
157
+ raise TypeError(
158
+ "PatchOp requires a type parameter. "
159
+ "Use PatchOp[YourResourceType] instead of PatchOp. "
160
+ "Example: PatchOp[User], PatchOp[Group], etc."
161
+ )
162
+
163
+ return super().__new__(cls)
164
+
165
+ def __class_getitem__(cls, item):
166
+ """Validate type parameter when creating parameterized type.
167
+
168
+ Ensures the type parameter is a concrete Resource subclass (not Resource itself).
169
+ Rejects invalid types (str, int, etc.) and Union types.
170
+ """
171
+ # Check if type parameter is a concrete Resource subclass (not Resource itself)
172
+ if item is Resource:
173
+ raise TypeError(
174
+ "PatchOp requires a concrete Resource subclass, not Resource itself. "
175
+ "Use PatchOp[User], PatchOp[Group], etc. instead of PatchOp[Resource]."
176
+ )
177
+ if not (isclass(item) and issubclass(item, Resource) and item is not Resource):
178
+ raise TypeError(
179
+ f"PatchOp type parameter must be a concrete Resource subclass, got {item}. "
180
+ "Use PatchOp[User], PatchOp[Group], etc."
181
+ )
182
+
183
+ return super().__class_getitem__(item)
184
+
185
+ schemas: Annotated[list[str], Required.true] = [
186
+ "urn:ietf:params:scim:api:messages:2.0:PatchOp"
187
+ ]
188
+
189
+ operations: Annotated[Optional[list[PatchOperation]], Required.true] = Field(
190
+ None, serialization_alias="Operations", min_length=1
191
+ )
192
+ """The body of an HTTP PATCH request MUST contain the attribute
193
+ "Operations", whose value is an array of one or more PATCH operations."""
194
+
195
+ @model_validator(mode="after")
196
+ def validate_operations(self) -> Self:
197
+ """Validate operations against resource type metadata if available.
198
+
199
+ When PatchOp is used with a specific resource type (e.g., PatchOp[User]),
200
+ this validator will automatically check mutability and required constraints.
201
+ """
202
+ resource_class = _get_resource_class(self)
203
+ if resource_class is None or not self.operations:
204
+ return self
205
+
206
+ # RFC 7644 Section 3.5.2: "Validate each operation against schema constraints"
207
+ for operation in self.operations:
208
+ if operation.path is None:
209
+ continue
210
+
211
+ field_name = _extract_field_name(operation.path)
212
+ operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type]
213
+ operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type]
214
+
215
+ return self
216
+
217
+ def patch(self, resource: T) -> bool:
218
+ """Apply all PATCH operations to the given SCIM resource in sequence.
219
+
220
+ The resource is modified in-place.
221
+
222
+ Each operation in the PatchOp is applied in order, modifying the resource in-place
223
+ according to :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. Supported operations are
224
+ "add", "replace", and "remove". If any operation modifies the resource, the method
225
+ returns True; otherwise, False.
226
+
227
+ :param resource: The SCIM resource to patch. This object is modified in-place.
228
+ :type resource: T
229
+ :return: True if the resource was modified by any operation, False otherwise.
230
+ :raises ValueError: If an operation is invalid (e.g., invalid path, forbidden mutation).
231
+ """
232
+ if not self.operations:
233
+ return False
234
+
235
+ modified = False
236
+ # RFC 7644 Section 3.5.2: "Apply each operation in sequence"
237
+ for operation in self.operations:
238
+ if self._apply_operation(resource, operation):
239
+ modified = True
240
+
241
+ return modified
242
+
243
+ def _apply_operation(self, resource: Resource, operation: PatchOperation) -> bool:
244
+ """Apply a single patch operation to a resource.
245
+
246
+ :return: :data:`True` if the resource was modified, else :data:`False`.
247
+ """
248
+ if operation.op in (PatchOperation.Op.add, PatchOperation.Op.replace_):
249
+ return self._apply_add_replace(resource, operation)
250
+ if operation.op == PatchOperation.Op.remove:
251
+ return self._apply_remove(resource, operation)
252
+
253
+ raise ValueError(Error.make_invalid_value_error().detail)
254
+
255
+ def _apply_add_replace(self, resource: Resource, operation: PatchOperation) -> bool:
256
+ """Apply an add or replace operation."""
257
+ # RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path"
258
+ if operation.path is not None:
259
+ return self._set_value_at_path(
260
+ resource,
261
+ operation.path,
262
+ operation.value,
263
+ is_add=operation.op == PatchOperation.Op.add,
264
+ )
265
+
266
+ # RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level"
267
+ return self._apply_root_attributes(resource, operation.value)
268
+
269
+ def _apply_remove(self, resource: Resource, operation: PatchOperation) -> bool:
270
+ """Apply a remove operation."""
271
+ # RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
272
+ if operation.path is None:
273
+ raise ValueError(Error.make_invalid_path_error().detail)
274
+
275
+ # RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value"
276
+ if operation.value is not None:
277
+ return self._remove_specific_value(
278
+ resource, operation.path, operation.value
279
+ )
280
+
281
+ return self._remove_value_at_path(resource, operation.path)
282
+
283
+ def _apply_root_attributes(self, resource: BaseModel, value: Any) -> bool:
284
+ """Apply attributes to the resource root."""
285
+ if not isinstance(value, dict):
286
+ return False
287
+
288
+ modified = False
289
+ for attr_name, val in value.items():
290
+ field_name = _find_field_name(type(resource), attr_name)
291
+ if not field_name:
292
+ continue
293
+
294
+ old_value = getattr(resource, field_name)
295
+ if old_value != val:
296
+ setattr(resource, field_name, val)
297
+ modified = True
298
+
299
+ return modified
300
+
301
+ def _set_value_at_path(
302
+ self, resource: Resource, path: str, value: Any, is_add: bool
303
+ ) -> bool:
304
+ """Set a value at a specific path."""
305
+ target, attr_path = _resolve_path_to_target(resource, path)
306
+
307
+ if not attr_path or not target:
308
+ raise ValueError(Error.make_invalid_path_error().detail)
309
+
310
+ path_parts = attr_path.split(".")
311
+ if len(path_parts) == 1:
312
+ return self._set_simple_attribute(target, path_parts[0], value, is_add)
313
+
314
+ return self._set_complex_attribute(target, path_parts, value, is_add)
315
+
316
+ def _set_simple_attribute(
317
+ self, resource: BaseModel, attr_name: str, value: Any, is_add: bool
318
+ ) -> bool:
319
+ """Set a value on a simple (non-nested) attribute."""
320
+ field_name = _find_field_name(type(resource), attr_name)
321
+ if not field_name:
322
+ raise ValueError(Error.make_no_target_error().detail)
323
+
324
+ # RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values"
325
+ if is_add and self._is_multivalued_field(resource, field_name):
326
+ return self._handle_multivalued_add(resource, field_name, value)
327
+
328
+ old_value = getattr(resource, field_name)
329
+ if old_value == value:
330
+ return False
331
+
332
+ setattr(resource, field_name, value)
333
+ return True
334
+
335
+ def _set_complex_attribute(
336
+ self, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
337
+ ) -> bool:
338
+ """Set a value on a complex (nested) attribute."""
339
+ parent_attr = path_parts[0]
340
+ sub_path = ".".join(path_parts[1:])
341
+
342
+ parent_field_name = _find_field_name(type(resource), parent_attr)
343
+ if not parent_field_name:
344
+ raise ValueError(Error.make_no_target_error().detail)
345
+
346
+ parent_obj = getattr(resource, parent_field_name)
347
+ if parent_obj is None:
348
+ parent_obj = self._create_parent_object(resource, parent_field_name)
349
+ if parent_obj is None:
350
+ return False
351
+
352
+ return self._set_value_at_path(parent_obj, sub_path, value, is_add)
353
+
354
+ def _is_multivalued_field(self, resource: BaseModel, field_name: str) -> bool:
355
+ """Check if a field is multi-valued."""
356
+ return hasattr(resource, field_name) and type(resource).get_field_multiplicity(
357
+ field_name
358
+ )
359
+
360
+ def _handle_multivalued_add(
361
+ self, resource: BaseModel, field_name: str, value: Any
362
+ ) -> bool:
363
+ """Handle adding values to a multi-valued attribute."""
364
+ current_list = getattr(resource, field_name) or []
365
+
366
+ # RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes"
367
+ if isinstance(value, list):
368
+ return self._add_multiple_values(resource, field_name, current_list, value)
369
+
370
+ return self._add_single_value(resource, field_name, current_list, value)
371
+
372
+ def _add_multiple_values(
373
+ self, resource: BaseModel, field_name: str, current_list: list, values: list
374
+ ) -> bool:
375
+ """Add multiple values to a multi-valued attribute."""
376
+ new_values = []
377
+ # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
378
+ for new_val in values:
379
+ if not self._value_exists_in_list(current_list, new_val):
380
+ new_values.append(new_val)
381
+
382
+ if not new_values:
383
+ return False
384
+
385
+ setattr(resource, field_name, current_list + new_values)
386
+ return True
387
+
388
+ def _add_single_value(
389
+ self, resource: BaseModel, field_name: str, current_list: list, value: Any
390
+ ) -> bool:
391
+ """Add a single value to a multi-valued attribute."""
392
+ # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
393
+ if self._value_exists_in_list(current_list, value):
394
+ return False
395
+
396
+ current_list.append(value)
397
+ setattr(resource, field_name, current_list)
398
+ return True
399
+
400
+ def _value_exists_in_list(self, current_list: list, new_value: Any) -> bool:
401
+ """Check if a value already exists in a list."""
402
+ return any(self._values_match(item, new_value) for item in current_list)
403
+
404
+ def _create_parent_object(self, resource: BaseModel, parent_field_name: str) -> Any:
405
+ """Create a parent object if it doesn't exist."""
406
+ parent_class = type(resource).get_field_root_type(parent_field_name)
407
+ if not parent_class or not isclass(parent_class):
408
+ return None
409
+
410
+ parent_obj = parent_class()
411
+ setattr(resource, parent_field_name, parent_obj)
412
+ return parent_obj
413
+
414
+ def _remove_value_at_path(self, resource: Resource, path: str) -> bool:
415
+ """Remove a value at a specific path."""
416
+ target, attr_path = _resolve_path_to_target(resource, path)
417
+
418
+ # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
419
+ if not attr_path or not target:
420
+ raise ValueError(Error.make_invalid_path_error().detail)
421
+
422
+ parent_attr, *path_parts = attr_path.split(".")
423
+ field_name = _find_field_name(type(target), parent_attr)
424
+ if not field_name:
425
+ raise ValueError(Error.make_no_target_error().detail)
426
+ parent_obj = getattr(target, field_name)
427
+
428
+ if parent_obj is None:
429
+ return False
430
+
431
+ # RFC 7644 Section 3.5.2.3: "Remove entire attribute if no sub-path"
432
+ if not path_parts:
433
+ setattr(target, field_name, None)
434
+ return True
435
+
436
+ sub_path = ".".join(path_parts)
437
+ return self._remove_value_at_path(parent_obj, sub_path)
438
+
439
+ def _remove_specific_value(
440
+ self, resource: Resource, path: str, value_to_remove: Any
441
+ ) -> bool:
442
+ """Remove a specific value from a multi-valued attribute."""
443
+ target, attr_path = _resolve_path_to_target(resource, path)
444
+
445
+ # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
446
+ if not attr_path or not target:
447
+ raise ValueError(Error.make_invalid_path_error().detail)
448
+
449
+ field_name = _find_field_name(type(target), attr_path)
450
+ if not field_name:
451
+ raise ValueError(Error.make_no_target_error().detail)
452
+
453
+ current_list = getattr(target, field_name)
454
+ if not isinstance(current_list, list):
455
+ return False
456
+
457
+ new_list = []
458
+ modified = False
459
+ # RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes"
460
+ for item in current_list:
461
+ if not self._values_match(item, value_to_remove):
462
+ new_list.append(item)
463
+ else:
464
+ modified = True
465
+
466
+ if modified:
467
+ setattr(target, field_name, new_list if new_list else None)
468
+ return True
469
+
470
+ return False
471
+
472
+ def _values_match(self, value1: Any, value2: Any) -> bool:
473
+ """Check if two values match, converting BaseModel to dict for comparison."""
474
+
475
+ def to_dict(value):
476
+ return value.model_dump() if isinstance(value, BaseModel) else value
477
+
478
+ return to_dict(value1) == to_dict(value2)
@@ -5,7 +5,9 @@ from typing import Optional
5
5
  from pydantic import field_validator
6
6
  from pydantic import model_validator
7
7
 
8
- from ..base import Required
8
+ from ..annotations import Required
9
+ from ..utils import _validate_scim_path_syntax
10
+ from .error import Error
9
11
  from .message import Message
10
12
 
11
13
 
@@ -24,10 +26,38 @@ class SearchRequest(Message):
24
26
  attributes to return in the response, overriding the set of attributes that
25
27
  would be returned by default."""
26
28
 
29
+ @field_validator("attributes")
30
+ @classmethod
31
+ def validate_attributes_syntax(cls, v: Optional[list[str]]) -> Optional[list[str]]:
32
+ """Validate syntax of attribute paths."""
33
+ if v is None:
34
+ return v
35
+
36
+ for attr in v:
37
+ if not _validate_scim_path_syntax(attr):
38
+ raise ValueError(Error.make_invalid_path_error().detail)
39
+
40
+ return v
41
+
27
42
  excluded_attributes: Optional[list[str]] = None
28
43
  """A multi-valued list of strings indicating the names of resource
29
44
  attributes to be removed from the default set of attributes to return."""
30
45
 
46
+ @field_validator("excluded_attributes")
47
+ @classmethod
48
+ def validate_excluded_attributes_syntax(
49
+ cls, v: Optional[list[str]]
50
+ ) -> Optional[list[str]]:
51
+ """Validate syntax of excluded attribute paths."""
52
+ if v is None:
53
+ return v
54
+
55
+ for attr in v:
56
+ if not _validate_scim_path_syntax(attr):
57
+ raise ValueError(Error.make_invalid_path_error().detail)
58
+
59
+ return v
60
+
31
61
  filter: Optional[str] = None
32
62
  """The filter string used to request a subset of resources."""
33
63
 
@@ -35,6 +65,25 @@ class SearchRequest(Message):
35
65
  """A string indicating the attribute whose value SHALL be used to order the
36
66
  returned responses."""
37
67
 
68
+ @field_validator("sort_by")
69
+ @classmethod
70
+ def validate_sort_by_syntax(cls, v: Optional[str]) -> Optional[str]:
71
+ """Validate syntax of sort_by attribute path.
72
+
73
+ :param v: The sort_by attribute path to validate
74
+ :type v: Optional[str]
75
+ :return: The validated sort_by attribute path
76
+ :rtype: Optional[str]
77
+ :raises ValueError: If sort_by attribute path has invalid syntax
78
+ """
79
+ if v is None:
80
+ return v
81
+
82
+ if not _validate_scim_path_syntax(v):
83
+ raise ValueError(Error.make_invalid_path_error().detail)
84
+
85
+ return v
86
+
38
87
  class SortOrder(str, Enum):
39
88
  ascending = "ascending"
40
89
  descending = "descending"
@@ -48,7 +97,7 @@ class SearchRequest(Message):
48
97
 
49
98
  @field_validator("start_index")
50
99
  @classmethod
51
- def start_index_floor(cls, value: int) -> int:
100
+ def start_index_floor(cls, value: Optional[int]) -> Optional[int]:
52
101
  """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1.
53
102
 
54
103
  A value less than 1 SHALL be interpreted as 1.
@@ -61,7 +110,7 @@ class SearchRequest(Message):
61
110
 
62
111
  @field_validator("count")
63
112
  @classmethod
64
- def count_floor(cls, value: int) -> int:
113
+ def count_floor(cls, value: Optional[int]) -> Optional[int]:
65
114
  """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0.
66
115
 
67
116
  A negative value SHALL be interpreted as 0.
@@ -69,7 +118,7 @@ class SearchRequest(Message):
69
118
  return None if value is None else max(0, value)
70
119
 
71
120
  @model_validator(mode="after")
72
- def attributes_validator(self):
121
+ def attributes_validator(self) -> "SearchRequest":
73
122
  if self.attributes and self.excluded_attributes:
74
123
  raise ValueError(
75
124
  "'attributes' and 'excluded_attributes' are mutually exclusive"
@@ -78,12 +127,12 @@ class SearchRequest(Message):
78
127
  return self
79
128
 
80
129
  @property
81
- def start_index_0(self):
130
+ def start_index_0(self) -> Optional[int]:
82
131
  """The 0 indexed start index."""
83
132
  return self.start_index - 1 if self.start_index is not None else None
84
133
 
85
134
  @property
86
- def stop_index_0(self):
135
+ def stop_index_0(self) -> Optional[int]:
87
136
  """The 0 indexed stop index."""
88
137
  return (
89
138
  self.start_index_0 + self.count
@@ -0,0 +1,82 @@
1
+ from collections import UserString
2
+ from typing import Any
3
+ from typing import Generic
4
+ from typing import TypeVar
5
+ from typing import get_args
6
+ from typing import get_origin
7
+
8
+ from pydantic import GetCoreSchemaHandler
9
+ from pydantic_core import core_schema
10
+ from typing_extensions import NewType
11
+
12
+ from .utils import UNION_TYPES
13
+
14
+ ReferenceTypes = TypeVar("ReferenceTypes")
15
+
16
+ URIReference = NewType("URIReference", str)
17
+ ExternalReference = NewType("ExternalReference", str)
18
+
19
+
20
+ class Reference(UserString, Generic[ReferenceTypes]):
21
+ """Reference type as defined in :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>`.
22
+
23
+ References can take different type parameters:
24
+
25
+ - Any :class:`~scim2_models.Resource` subtype, or :class:`~typing.ForwardRef` of a Resource subtype, or :data:`~typing.Union` of those,
26
+ - :data:`~scim2_models.ExternalReference`
27
+ - :data:`~scim2_models.URIReference`
28
+
29
+ Examples
30
+ --------
31
+
32
+ .. code-block:: python
33
+
34
+ class Foobar(Resource):
35
+ bff: Reference[User]
36
+ managers: Reference[Union["User", "Group"]]
37
+ photo: Reference[ExternalReference]
38
+ website: Reference[URIReference]
39
+
40
+ """
41
+
42
+ @classmethod
43
+ def __get_pydantic_core_schema__(
44
+ cls,
45
+ _source: type[Any],
46
+ _handler: GetCoreSchemaHandler,
47
+ ) -> core_schema.CoreSchema:
48
+ return core_schema.no_info_after_validator_function(
49
+ cls._validate,
50
+ core_schema.union_schema(
51
+ [core_schema.str_schema(), core_schema.is_instance_schema(cls)]
52
+ ),
53
+ )
54
+
55
+ @classmethod
56
+ def _validate(cls, input_value: Any, /) -> str:
57
+ return str(input_value)
58
+
59
+ @classmethod
60
+ def get_types(cls, type_annotation: Any) -> list[str]:
61
+ """Get reference types from a type annotation.
62
+
63
+ :param type_annotation: Type annotation to extract reference types from
64
+ :type type_annotation: Any
65
+ :return: List of reference type strings
66
+ :rtype: list[str]
67
+ """
68
+ first_arg = get_args(type_annotation)[0]
69
+ types = (
70
+ get_args(first_arg) if get_origin(first_arg) in UNION_TYPES else [first_arg]
71
+ )
72
+
73
+ def serialize_ref_type(ref_type: Any) -> str:
74
+ if ref_type == URIReference:
75
+ return "uri"
76
+
77
+ elif ref_type == ExternalReference:
78
+ return "external"
79
+
80
+ return str(get_args(ref_type)[0])
81
+
82
+ return list(map(serialize_ref_type, types))
@@ -4,10 +4,10 @@ from typing import Optional
4
4
 
5
5
  from pydantic import Field
6
6
 
7
- from ..base import ComplexAttribute
8
- from ..base import Mutability
9
- from ..base import Reference
10
- from ..base import Required
7
+ from ..annotations import Mutability
8
+ from ..annotations import Required
9
+ from ..attributes import ComplexAttribute
10
+ from ..reference import Reference
11
11
  from .resource import Extension
12
12
 
13
13