scim2-models 0.5.2__py3-none-any.whl → 0.6.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.
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from typing import Generic
6
6
  from typing import TypeVar
7
7
 
8
+ from pydantic import BaseModel as PydanticBaseModel
8
9
  from pydantic import Field
9
10
  from pydantic import ValidationInfo
10
11
  from pydantic import field_validator
@@ -14,22 +15,20 @@ from typing_extensions import Self
14
15
  from ..annotations import Mutability
15
16
  from ..annotations import Required
16
17
  from ..attributes import ComplexAttribute
17
- from ..base import BaseModel
18
18
  from ..context import Context
19
+ from ..exceptions import InvalidValueException
20
+ from ..exceptions import MutabilityException
21
+ from ..exceptions import NoTargetException
22
+ from ..path import URN
23
+ from ..path import Path
19
24
  from ..resources.resource import Resource
20
- from ..urn import _resolve_path_to_target
21
- from ..utils import _extract_field_name
22
- from ..utils import _find_field_name
23
- from ..utils import _get_path_parts
24
- from ..utils import _validate_scim_path_syntax
25
- from .error import Error
26
25
  from .message import Message
27
26
  from .message import _get_resource_class
28
27
 
29
- T = TypeVar("T", bound=Resource[Any])
28
+ ResourceT = TypeVar("ResourceT", bound=Resource[Any])
30
29
 
31
30
 
32
- class PatchOperation(ComplexAttribute):
31
+ class PatchOperation(ComplexAttribute, Generic[ResourceT]):
33
32
  class Op(str, Enum):
34
33
  replace_ = "replace"
35
34
  remove = "remove"
@@ -46,7 +45,7 @@ class PatchOperation(ComplexAttribute):
46
45
  despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive.
47
46
  """
48
47
 
49
- path: str | None = None
48
+ path: Path[ResourceT] | None = None
50
49
  """The "path" attribute value is a String containing an attribute path
51
50
  describing the target of the operation."""
52
51
 
@@ -65,11 +64,15 @@ class PatchOperation(ComplexAttribute):
65
64
  PatchOperation.Op.add,
66
65
  PatchOperation.Op.replace_,
67
66
  ):
68
- raise ValueError(Error.make_mutability_error().detail)
67
+ raise MutabilityException(
68
+ attribute=field_name, mutability="readOnly", operation=self.op.value
69
+ ).as_pydantic_error()
69
70
 
70
71
  # RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated"
71
72
  if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_:
72
- raise ValueError(Error.make_mutability_error().detail)
73
+ raise MutabilityException(
74
+ attribute=field_name, mutability="immutable", operation=self.op.value
75
+ ).as_pydantic_error()
73
76
 
74
77
  def _validate_required_attribute(
75
78
  self, resource_class: type[Resource[Any]], field_name: str
@@ -87,7 +90,9 @@ class PatchOperation(ComplexAttribute):
87
90
 
88
91
  # RFC 7643 Section 7: "Required attributes SHALL NOT be removed"
89
92
  if required == Required.true:
90
- raise ValueError(Error.make_invalid_value_error().detail)
93
+ raise InvalidValueException(
94
+ detail="required attribute cannot be removed", attribute=field_name
95
+ ).as_pydantic_error()
91
96
 
92
97
  @model_validator(mode="after")
93
98
  def validate_operation_requirements(self, info: ValidationInfo) -> Self:
@@ -97,17 +102,18 @@ class PatchOperation(ComplexAttribute):
97
102
  if scim_ctx != Context.RESOURCE_PATCH_REQUEST:
98
103
  return self
99
104
 
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
+ # RFC 7644 Section 3.5.2.2: "If 'path' is unspecified, the operation
106
+ # fails with HTTP status code 400 and a 'scimType' error of 'noTarget'"
105
107
  if self.path is None and self.op == PatchOperation.Op.remove:
106
- raise ValueError(Error.make_invalid_path_error().detail)
108
+ raise NoTargetException(
109
+ detail="Remove operation requires a path"
110
+ ).as_pydantic_error()
107
111
 
108
112
  # RFC 7644 Section 3.5.2.1: "Value is required for add operations"
109
113
  if self.op == PatchOperation.Op.add and self.value is None:
110
- raise ValueError(Error.make_invalid_value_error().detail)
114
+ raise InvalidValueException(
115
+ detail="value is required for add operations"
116
+ ).as_pydantic_error()
111
117
 
112
118
  return self
113
119
 
@@ -130,10 +136,10 @@ class PatchOperation(ComplexAttribute):
130
136
  return v
131
137
 
132
138
 
133
- class PatchOp(Message, Generic[T]):
139
+ class PatchOp(Message, Generic[ResourceT]):
134
140
  """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
135
141
 
136
- Type parameter T is required and must be a concrete Resource subclass.
142
+ Type parameter ResourceT is required and must be a concrete Resource subclass.
137
143
  Usage: PatchOp[User], PatchOp[Group], etc.
138
144
 
139
145
  .. note::
@@ -205,12 +211,10 @@ class PatchOp(Message, Generic[T]):
205
211
 
206
212
  return super().__class_getitem__(typevar_values)
207
213
 
208
- schemas: Annotated[list[str], Required.true] = [
209
- "urn:ietf:params:scim:api:messages:2.0:PatchOp"
210
- ]
214
+ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:PatchOp")
211
215
 
212
- operations: Annotated[list[PatchOperation] | None, Required.true] = Field(
213
- None, serialization_alias="Operations", min_length=1
216
+ operations: Annotated[list[PatchOperation[ResourceT]] | None, Required.true] = (
217
+ Field(None, serialization_alias="Operations", min_length=1)
214
218
  )
215
219
  """The body of an HTTP PATCH request MUST contain the attribute
216
220
  "Operations", whose value is an array of one or more PATCH operations."""
@@ -225,7 +229,9 @@ class PatchOp(Message, Generic[T]):
225
229
  # RFC 7644: The body of an HTTP PATCH request MUST contain the attribute "Operations"
226
230
  scim_ctx = info.context.get("scim") if info.context else None
227
231
  if scim_ctx == Context.RESOURCE_PATCH_REQUEST and self.operations is None:
228
- raise ValueError(Error.make_invalid_value_error().detail)
232
+ raise InvalidValueException(
233
+ detail="operations attribute is required"
234
+ ).as_pydantic_error()
229
235
 
230
236
  resource_class = _get_resource_class(self)
231
237
  if resource_class is None or not self.operations:
@@ -236,13 +242,13 @@ class PatchOp(Message, Generic[T]):
236
242
  if operation.path is None:
237
243
  continue
238
244
 
239
- field_name = _extract_field_name(operation.path)
245
+ field_name = operation.path.parts[0] if operation.path.parts else None
240
246
  operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type]
241
247
  operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type]
242
248
 
243
249
  return self
244
250
 
245
- def patch(self, resource: T) -> bool:
251
+ def patch(self, resource: ResourceT) -> bool:
246
252
  """Apply all PATCH operations to the given SCIM resource in sequence.
247
253
 
248
254
  The resource is modified in-place.
@@ -252,10 +258,14 @@ class PatchOp(Message, Generic[T]):
252
258
  "add", "replace", and "remove". If any operation modifies the resource, the method
253
259
  returns True; otherwise, False.
254
260
 
261
+ Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`, when an operation sets a value's
262
+ ``primary`` sub-attribute to ``True``, any other values in the same multi-valued
263
+ attribute will have their ``primary`` set to ``False`` automatically.
264
+
255
265
  :param resource: The SCIM resource to patch. This object is modified in-place.
256
- :type resource: T
257
266
  :return: True if the resource was modified by any operation, False otherwise.
258
- :raises ValueError: If an operation is invalid (e.g., invalid path, forbidden mutation).
267
+ :raises InvalidValueException: If multiple values are marked as primary in a single
268
+ operation, or if multiple primary values already exist before the patch.
259
269
  """
260
270
  if not self.operations:
261
271
  return False
@@ -269,7 +279,7 @@ class PatchOp(Message, Generic[T]):
269
279
  return modified
270
280
 
271
281
  def _apply_operation(
272
- self, resource: Resource[Any], operation: PatchOperation
282
+ self, resource: Resource[Any], operation: PatchOperation[ResourceT]
273
283
  ) -> bool:
274
284
  """Apply a single patch operation to a resource.
275
285
 
@@ -280,257 +290,113 @@ class PatchOp(Message, Generic[T]):
280
290
  if operation.op == PatchOperation.Op.remove:
281
291
  return self._apply_remove(resource, operation)
282
292
 
283
- raise ValueError(Error.make_invalid_value_error().detail)
293
+ raise InvalidValueException(detail=f"unsupported operation: {operation.op}")
284
294
 
285
295
  def _apply_add_replace(
286
- self, resource: Resource[Any], operation: PatchOperation
296
+ self, resource: Resource[Any], operation: PatchOperation[ResourceT]
287
297
  ) -> bool:
288
298
  """Apply an add or replace operation."""
289
- # RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path"
290
- if operation.path is not None:
291
- return self._set_value_at_path(
292
- resource,
293
- operation.path,
294
- operation.value,
295
- is_add=operation.op == PatchOperation.Op.add,
296
- )
297
-
298
- # RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level"
299
- return self._apply_root_attributes(resource, operation.value)
300
-
301
- def _apply_remove(self, resource: Resource[Any], operation: PatchOperation) -> bool:
302
- """Apply a remove operation."""
303
- # RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
304
- if operation.path is None:
305
- raise ValueError(Error.make_invalid_path_error().detail)
306
-
307
- # RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value"
308
- if operation.value is not None:
309
- return self._remove_specific_value(
310
- resource, operation.path, operation.value
311
- )
312
-
313
- return self._remove_value_at_path(resource, operation.path)
314
-
315
- @classmethod
316
- def _apply_root_attributes(cls, resource: BaseModel, value: Any) -> bool:
317
- """Apply attributes to the resource root."""
318
- if not isinstance(value, dict):
319
- return False
299
+ before_state = self._capture_primary_state(resource)
320
300
 
321
- modified = False
322
- for attr_name, val in value.items():
323
- field_name = _find_field_name(type(resource), attr_name)
324
- if not field_name:
325
- continue
301
+ path = operation.path if operation.path is not None else Path("")
302
+ modified = path.set(
303
+ resource, # type: ignore[arg-type]
304
+ operation.value,
305
+ is_add=operation.op == PatchOperation.Op.add,
306
+ )
326
307
 
327
- old_value = getattr(resource, field_name)
328
- if old_value != val:
329
- setattr(resource, field_name, val)
330
- modified = True
308
+ if modified:
309
+ self._normalize_primary_after_patch(resource, before_state)
331
310
 
332
311
  return modified
333
312
 
334
- @classmethod
335
- def _set_value_at_path(
336
- cls, resource: Resource[Any], path: str, value: Any, is_add: bool
337
- ) -> bool:
338
- """Set a value at a specific path."""
339
- target, attr_path = _resolve_path_to_target(resource, path)
340
-
341
- if not target:
342
- raise ValueError(Error.make_invalid_path_error().detail)
343
-
344
- if not attr_path:
345
- if not isinstance(value, dict):
346
- raise ValueError(Error.make_invalid_path_error().detail)
347
-
348
- updated_data = {**target.model_dump(), **value}
349
- updated_target = type(target).model_validate(updated_data)
350
- target.__dict__.update(updated_target.__dict__)
351
- return True
352
-
353
- path_parts = _get_path_parts(attr_path)
354
- if len(path_parts) == 1:
355
- return cls._set_simple_attribute(target, path_parts[0], value, is_add)
356
-
357
- return cls._set_complex_attribute(target, path_parts, value, is_add)
358
-
359
- @classmethod
360
- def _set_simple_attribute(
361
- cls, resource: BaseModel, attr_name: str, value: Any, is_add: bool
362
- ) -> bool:
363
- """Set a value on a simple (non-nested) attribute."""
364
- field_name = _find_field_name(type(resource), attr_name)
365
- if not field_name:
366
- raise ValueError(Error.make_no_target_error().detail)
367
-
368
- # RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values"
369
- if is_add and cls._is_multivalued_field(resource, field_name):
370
- return cls._handle_multivalued_add(resource, field_name, value)
371
-
372
- old_value = getattr(resource, field_name)
373
- if old_value == value:
374
- return False
375
-
376
- setattr(resource, field_name, value)
377
- return True
378
-
379
- @classmethod
380
- def _set_complex_attribute(
381
- cls, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
382
- ) -> bool:
383
- """Set a value on a complex (nested) attribute."""
384
- parent_attr = path_parts[0]
385
- sub_path = ".".join(path_parts[1:])
386
-
387
- parent_field_name = _find_field_name(type(resource), parent_attr)
388
- if not parent_field_name:
389
- raise ValueError(Error.make_no_target_error().detail)
390
-
391
- parent_obj = getattr(resource, parent_field_name)
392
- if parent_obj is None:
393
- parent_obj = cls._create_parent_object(resource, parent_field_name)
394
- if parent_obj is None:
395
- return False
396
-
397
- return cls._set_value_at_path(parent_obj, sub_path, value, is_add)
398
-
399
- @classmethod
400
- def _is_multivalued_field(cls, resource: BaseModel, field_name: str) -> bool:
401
- """Check if a field is multi-valued."""
402
- return hasattr(resource, field_name) and type(resource).get_field_multiplicity(
403
- field_name
404
- )
405
-
406
- @classmethod
407
- def _handle_multivalued_add(
408
- cls, resource: BaseModel, field_name: str, value: Any
409
- ) -> bool:
410
- """Handle adding values to a multi-valued attribute."""
411
- current_list = getattr(resource, field_name) or []
313
+ def _capture_primary_state(self, resource: Resource[Any]) -> dict[str, set[int]]:
314
+ """Capture indices of elements with primary=True for each multi-valued attribute."""
315
+ state: dict[str, set[int]] = {}
316
+ for field_name in type(resource).model_fields:
317
+ if not resource.get_field_multiplicity(field_name):
318
+ continue
412
319
 
413
- # RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes"
414
- if isinstance(value, list):
415
- return cls._add_multiple_values(resource, field_name, current_list, value)
320
+ field_value = getattr(resource, field_name, None)
321
+ if not field_value:
322
+ continue
416
323
 
417
- return cls._add_single_value(resource, field_name, current_list, value)
324
+ element_type = resource.get_field_root_type(field_name)
325
+ if (
326
+ not element_type
327
+ or not isclass(element_type)
328
+ or not issubclass(element_type, PydanticBaseModel)
329
+ or "primary" not in element_type.model_fields
330
+ ):
331
+ continue
418
332
 
419
- @classmethod
420
- def _add_multiple_values(
421
- cls,
422
- resource: BaseModel,
423
- field_name: str,
424
- current_list: list[Any],
425
- values: list[Any],
426
- ) -> bool:
427
- """Add multiple values to a multi-valued attribute."""
428
- new_values = []
429
- # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
430
- for new_val in values:
431
- if not cls._value_exists_in_list(current_list, new_val):
432
- new_values.append(new_val)
433
-
434
- if not new_values:
435
- return False
333
+ primary_indices = {
334
+ i
335
+ for i, item in enumerate(field_value)
336
+ if getattr(item, "primary", None) is True
337
+ }
338
+ state[field_name] = primary_indices
436
339
 
437
- setattr(resource, field_name, current_list + new_values)
438
- return True
340
+ return state
439
341
 
440
- @classmethod
441
- def _add_single_value(
442
- cls, resource: BaseModel, field_name: str, current_list: list[Any], value: Any
443
- ) -> bool:
444
- """Add a single value to a multi-valued attribute."""
445
- # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
446
- if cls._value_exists_in_list(current_list, value):
447
- return False
448
-
449
- current_list.append(value)
450
- setattr(resource, field_name, current_list)
451
- return True
342
+ def _normalize_primary_after_patch(
343
+ self, resource: Resource[Any], before_state: dict[str, set[int]]
344
+ ) -> None:
345
+ """Normalize primary attributes after a patch operation.
452
346
 
453
- @classmethod
454
- def _value_exists_in_list(cls, current_list: list[Any], new_value: Any) -> bool:
455
- """Check if a value already exists in a list."""
456
- return any(cls._values_match(item, new_value) for item in current_list)
347
+ Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: a PATCH operation that
348
+ sets a value's "primary" sub-attribute to "true" SHALL cause the server
349
+ to automatically set "primary" to "false" for any other values.
350
+ """
351
+ for field_name in type(resource).model_fields:
352
+ if not resource.get_field_multiplicity(field_name):
353
+ continue
457
354
 
458
- @classmethod
459
- def _create_parent_object(cls, resource: BaseModel, parent_field_name: str) -> Any:
460
- """Create a parent object if it doesn't exist."""
461
- parent_class = type(resource).get_field_root_type(parent_field_name)
462
- if not parent_class or not isclass(parent_class):
463
- return None
355
+ field_value = getattr(resource, field_name, None)
356
+ if not field_value:
357
+ continue
464
358
 
465
- parent_obj = parent_class()
466
- setattr(resource, parent_field_name, parent_obj)
467
- return parent_obj
359
+ element_type = resource.get_field_root_type(field_name)
360
+ if (
361
+ not element_type
362
+ or not isclass(element_type)
363
+ or not issubclass(element_type, PydanticBaseModel)
364
+ or "primary" not in element_type.model_fields
365
+ ):
366
+ continue
468
367
 
469
- @classmethod
470
- def _remove_value_at_path(cls, resource: Resource[Any], path: str) -> bool:
471
- """Remove a value at a specific path."""
472
- target, attr_path = _resolve_path_to_target(resource, path)
368
+ current_primary_indices = {
369
+ i
370
+ for i, item in enumerate(field_value)
371
+ if getattr(item, "primary", None) is True
372
+ }
473
373
 
474
- # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
475
- if not attr_path or not target:
476
- raise ValueError(Error.make_invalid_path_error().detail)
374
+ if len(current_primary_indices) <= 1:
375
+ continue
477
376
 
478
- parent_attr, *path_parts = _get_path_parts(attr_path)
479
- field_name = _find_field_name(type(target), parent_attr)
480
- if not field_name:
481
- raise ValueError(Error.make_no_target_error().detail)
482
- parent_obj = getattr(target, field_name)
377
+ before_primaries = before_state.get(field_name, set())
378
+ new_primaries = current_primary_indices - before_primaries
483
379
 
484
- if parent_obj is None:
485
- return False
380
+ if len(new_primaries) > 1:
381
+ raise InvalidValueException(
382
+ detail=f"Multiple values marked as primary in field '{field_name}'"
383
+ )
486
384
 
487
- # RFC 7644 Section 3.5.2.3: "Remove entire attribute if no sub-path"
488
- if not path_parts:
489
- setattr(target, field_name, None)
490
- return True
385
+ if not new_primaries:
386
+ raise InvalidValueException(
387
+ detail=f"Multiple primary values already exist in field '{field_name}'"
388
+ )
491
389
 
492
- sub_path = ".".join(path_parts)
493
- return cls._remove_value_at_path(parent_obj, sub_path)
390
+ keep_index = next(iter(new_primaries))
391
+ for i in current_primary_indices:
392
+ if i != keep_index:
393
+ field_value[i].primary = False
494
394
 
495
- @classmethod
496
- def _remove_specific_value(
497
- cls, resource: Resource[Any], path: str, value_to_remove: Any
395
+ def _apply_remove(
396
+ self, resource: Resource[Any], operation: PatchOperation[ResourceT]
498
397
  ) -> bool:
499
- """Remove a specific value from a multi-valued attribute."""
500
- target, attr_path = _resolve_path_to_target(resource, path)
501
-
502
- # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute"
503
- if not attr_path or not target:
504
- raise ValueError(Error.make_invalid_path_error().detail)
505
-
506
- field_name = _find_field_name(type(target), attr_path)
507
- if not field_name:
508
- raise ValueError(Error.make_no_target_error().detail)
509
-
510
- current_list = getattr(target, field_name)
511
- if not isinstance(current_list, list):
512
- return False
513
-
514
- new_list = []
515
- modified = False
516
- # RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes"
517
- for item in current_list:
518
- if not cls._values_match(item, value_to_remove):
519
- new_list.append(item)
520
- else:
521
- modified = True
522
-
523
- if modified:
524
- setattr(target, field_name, new_list if new_list else None)
525
- return True
526
-
527
- return False
528
-
529
- @classmethod
530
- def _values_match(cls, value1: Any, value2: Any) -> bool:
531
- """Check if two values match, converting BaseModel to dict for comparison."""
532
-
533
- def to_dict(value: Any) -> dict[str, Any]:
534
- return value.model_dump() if isinstance(value, BaseModel) else value
398
+ """Apply a remove operation."""
399
+ if operation.path is None:
400
+ raise NoTargetException(detail="Remove operation requires a path")
535
401
 
536
- return to_dict(value1) == to_dict(value2)
402
+ return operation.path.delete(resource, operation.value) # type: ignore[arg-type]
@@ -1,12 +1,11 @@
1
1
  from enum import Enum
2
- from typing import Annotated
2
+ from typing import Any
3
3
 
4
4
  from pydantic import field_validator
5
5
  from pydantic import model_validator
6
6
 
7
- from ..annotations import Required
8
- from ..utils import _validate_scim_path_syntax
9
- from .error import Error
7
+ from ..path import URN
8
+ from ..path import Path
10
9
  from .message import Message
11
10
 
12
11
 
@@ -16,71 +15,24 @@ class SearchRequest(Message):
16
15
  https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3
17
16
  """
18
17
 
19
- schemas: Annotated[list[str], Required.true] = [
20
- "urn:ietf:params:scim:api:messages:2.0:SearchRequest"
21
- ]
18
+ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest")
22
19
 
23
- attributes: list[str] | None = None
20
+ attributes: list[Path[Any]] | None = None
24
21
  """A multi-valued list of strings indicating the names of resource
25
22
  attributes to return in the response, overriding the set of attributes that
26
23
  would be returned by default."""
27
24
 
28
- @field_validator("attributes")
29
- @classmethod
30
- def validate_attributes_syntax(cls, v: list[str] | None) -> list[str] | None:
31
- """Validate syntax of attribute paths."""
32
- if v is None:
33
- return v
34
-
35
- for attr in v:
36
- if not _validate_scim_path_syntax(attr):
37
- raise ValueError(Error.make_invalid_path_error().detail)
38
-
39
- return v
40
-
41
- excluded_attributes: list[str] | None = None
25
+ excluded_attributes: list[Path[Any]] | None = None
42
26
  """A multi-valued list of strings indicating the names of resource
43
27
  attributes to be removed from the default set of attributes to return."""
44
28
 
45
- @field_validator("excluded_attributes")
46
- @classmethod
47
- def validate_excluded_attributes_syntax(
48
- cls, v: list[str] | None
49
- ) -> list[str] | None:
50
- """Validate syntax of excluded attribute paths."""
51
- if v is None:
52
- return v
53
-
54
- for attr in v:
55
- if not _validate_scim_path_syntax(attr):
56
- raise ValueError(Error.make_invalid_path_error().detail)
57
-
58
- return v
59
-
60
29
  filter: str | None = None
61
30
  """The filter string used to request a subset of resources."""
62
31
 
63
- sort_by: str | None = None
32
+ sort_by: Path[Any] | None = None
64
33
  """A string indicating the attribute whose value SHALL be used to order the
65
34
  returned responses."""
66
35
 
67
- @field_validator("sort_by")
68
- @classmethod
69
- def validate_sort_by_syntax(cls, v: str | None) -> str | None:
70
- """Validate syntax of sort_by attribute path.
71
-
72
- :param v: The sort_by attribute path to validate
73
- :return: The validated sort_by attribute path
74
- :raises ValueError: If sort_by attribute path has invalid syntax
75
- """
76
- if v is None:
77
- return v
78
-
79
- if not _validate_scim_path_syntax(v):
80
- raise ValueError(Error.make_invalid_path_error().detail)
81
-
82
- return v
83
-
84
36
  class SortOrder(str, Enum):
85
37
  ascending = "ascending"
86
38
  descending = "descending"
@@ -95,7 +47,7 @@ class SearchRequest(Message):
95
47
  @field_validator("start_index")
96
48
  @classmethod
97
49
  def start_index_floor(cls, value: int | None) -> int | None:
98
- """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1.
50
+ """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>`, start_index values less than 1 are interpreted as 1.
99
51
 
100
52
  A value less than 1 SHALL be interpreted as 1.
101
53
  """
@@ -108,7 +60,7 @@ class SearchRequest(Message):
108
60
  @field_validator("count")
109
61
  @classmethod
110
62
  def count_floor(cls, value: int | None) -> int | None:
111
- """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0.
63
+ """According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>`, count values less than 0 are interpreted as 0.
112
64
 
113
65
  A negative value SHALL be interpreted as 0.
114
66
  """