scim2-client 0.5.2__py3-none-any.whl → 0.6.1__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.
scim2_client/client.py CHANGED
@@ -3,6 +3,7 @@ import sys
3
3
  from collections.abc import Collection
4
4
  from dataclasses import dataclass
5
5
  from typing import Optional
6
+ from typing import TypeVar
6
7
  from typing import Union
7
8
 
8
9
  from pydantic import ValidationError
@@ -27,6 +28,8 @@ from scim2_client.errors import SCIMResponseErrorObject
27
28
  from scim2_client.errors import UnexpectedContentType
28
29
  from scim2_client.errors import UnexpectedStatusCode
29
30
 
31
+ ResourceT = TypeVar("ResourceT", bound=Resource)
32
+
30
33
  BASE_HEADERS = {
31
34
  "Accept": "application/scim+json",
32
35
  "Content-Type": "application/scim+json",
@@ -151,6 +154,26 @@ class SCIMClient:
151
154
  :rfc:`RFC7644 §3.12 <7644#section-3.12>`.
152
155
  """
153
156
 
157
+ PATCH_RESPONSE_STATUS_CODES: list[int] = [
158
+ 200,
159
+ 204,
160
+ 307,
161
+ 308,
162
+ 400,
163
+ 401,
164
+ 403,
165
+ 404,
166
+ 409,
167
+ 412,
168
+ 500,
169
+ 501,
170
+ ]
171
+ """Resource patching HTTP codes.
172
+
173
+ As defined at :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` and
174
+ :rfc:`RFC7644 §3.12 <7644#section-3.12>`.
175
+ """
176
+
154
177
  def __init__(
155
178
  self,
156
179
  resource_models: Optional[Collection[type[Resource]]] = None,
@@ -179,13 +202,16 @@ class SCIMClient:
179
202
  return resource_model
180
203
  return None
181
204
 
182
- def check_resource_model(
205
+ def _check_resource_model(
183
206
  self, resource_model: type[Resource], payload=None
184
207
  ) -> None:
185
- if (
186
- resource_model not in self.resource_models
187
- and resource_model not in CONFIG_RESOURCES
188
- ):
208
+ schema_to_check = resource_model.model_fields["schemas"].default[0]
209
+ for element in self.resource_models:
210
+ schema = element.model_fields["schemas"].default[0]
211
+ if schema_to_check == schema:
212
+ return
213
+
214
+ if resource_model not in CONFIG_RESOURCES:
189
215
  raise SCIMRequestError(
190
216
  f"Unknown resource type: '{resource_model}'", source=payload
191
217
  )
@@ -299,11 +325,15 @@ class SCIMClient:
299
325
  if not expected_types:
300
326
  return response_payload
301
327
 
328
+ # For no-content responses, return None directly
329
+ if response_payload is None:
330
+ return None
331
+
302
332
  actual_type = Resource.get_by_payload(
303
333
  expected_types, response_payload, with_extensions=False
304
334
  )
305
335
 
306
- if response_payload and not actual_type:
336
+ if not actual_type:
307
337
  expected = ", ".join([type_.__name__ for type_ in expected_types])
308
338
  try:
309
339
  schema = ", ".join(response_payload["schemas"])
@@ -323,12 +353,11 @@ class SCIMClient:
323
353
  scim_exc.add_note(str(exc))
324
354
  raise scim_exc from exc
325
355
 
326
- def prepare_create_request(
356
+ def _prepare_create_request(
327
357
  self,
328
358
  resource: Union[AnyResource, dict],
329
359
  check_request_payload: Optional[bool] = None,
330
360
  expected_status_codes: Optional[list[int]] = None,
331
- raise_scim_errors: Optional[bool] = None,
332
361
  **kwargs,
333
362
  ) -> RequestPayload:
334
363
  req = RequestPayload(
@@ -361,7 +390,7 @@ class SCIMClient:
361
390
  scim_validation_exc.add_note(str(exc))
362
391
  raise scim_validation_exc from exc
363
392
 
364
- self.check_resource_model(resource_model, resource)
393
+ self._check_resource_model(resource_model, resource)
365
394
  req.expected_types = [resource.__class__]
366
395
  req.url = req.request_kwargs.pop(
367
396
  "url", self.resource_endpoint(resource_model)
@@ -372,14 +401,13 @@ class SCIMClient:
372
401
 
373
402
  return req
374
403
 
375
- def prepare_query_request(
404
+ def _prepare_query_request(
376
405
  self,
377
406
  resource_model: Optional[type[Resource]] = None,
378
407
  id: Optional[str] = None,
379
408
  search_request: Optional[Union[SearchRequest, dict]] = None,
380
409
  check_request_payload: Optional[bool] = None,
381
410
  expected_status_codes: Optional[list[int]] = None,
382
- raise_scim_errors: Optional[bool] = None,
383
411
  **kwargs,
384
412
  ) -> RequestPayload:
385
413
  req = RequestPayload(
@@ -391,7 +419,7 @@ class SCIMClient:
391
419
  check_request_payload = self.check_request_payload
392
420
 
393
421
  if resource_model and check_request_payload:
394
- self.check_resource_model(resource_model)
422
+ self._check_resource_model(resource_model)
395
423
 
396
424
  payload: Optional[SearchRequest]
397
425
  if not check_request_payload:
@@ -429,12 +457,11 @@ class SCIMClient:
429
457
 
430
458
  return req
431
459
 
432
- def prepare_search_request(
460
+ def _prepare_search_request(
433
461
  self,
434
462
  search_request: Optional[SearchRequest] = None,
435
463
  check_request_payload: Optional[bool] = None,
436
464
  expected_status_codes: Optional[list[int]] = None,
437
- raise_scim_errors: Optional[bool] = None,
438
465
  **kwargs,
439
466
  ) -> RequestPayload:
440
467
  req = RequestPayload(
@@ -461,12 +488,11 @@ class SCIMClient:
461
488
  req.expected_types = [ListResponse[Union[self.resource_models]]]
462
489
  return req
463
490
 
464
- def prepare_delete_request(
491
+ def _prepare_delete_request(
465
492
  self,
466
493
  resource_model: type,
467
494
  id: str,
468
495
  expected_status_codes: Optional[list[int]] = None,
469
- raise_scim_errors: Optional[bool] = None,
470
496
  **kwargs,
471
497
  ) -> RequestPayload:
472
498
  req = RequestPayload(
@@ -474,17 +500,16 @@ class SCIMClient:
474
500
  request_kwargs=kwargs,
475
501
  )
476
502
 
477
- self.check_resource_model(resource_model)
503
+ self._check_resource_model(resource_model)
478
504
  delete_url = self.resource_endpoint(resource_model) + f"/{id}"
479
505
  req.url = req.request_kwargs.pop("url", delete_url)
480
506
  return req
481
507
 
482
- def prepare_replace_request(
508
+ def _prepare_replace_request(
483
509
  self,
484
510
  resource: Union[AnyResource, dict],
485
511
  check_request_payload: Optional[bool] = None,
486
512
  expected_status_codes: Optional[list[int]] = None,
487
- raise_scim_errors: Optional[bool] = None,
488
513
  **kwargs,
489
514
  ) -> RequestPayload:
490
515
  req = RequestPayload(
@@ -519,7 +544,7 @@ class SCIMClient:
519
544
  scim_validation_exc.add_note(str(exc))
520
545
  raise scim_validation_exc from exc
521
546
 
522
- self.check_resource_model(resource_model, resource)
547
+ self._check_resource_model(resource_model, resource)
523
548
 
524
549
  if not resource.id:
525
550
  raise SCIMRequestError("Resource must have an id", source=resource)
@@ -534,9 +559,75 @@ class SCIMClient:
534
559
 
535
560
  return req
536
561
 
562
+ def _prepare_patch_request(
563
+ self,
564
+ resource_model: type[ResourceT],
565
+ id: str,
566
+ patch_op: Union[PatchOp[ResourceT], dict],
567
+ check_request_payload: Optional[bool] = None,
568
+ expected_status_codes: Optional[list[int]] = None,
569
+ **kwargs,
570
+ ) -> RequestPayload:
571
+ """Prepare a PATCH request payload.
572
+
573
+ :param resource_model: The resource type to modify (e.g., User, Group).
574
+ :param id: The resource ID.
575
+ :param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
576
+ (e.g., PatchOp[User] when resource_model is User), or a dict representation.
577
+ :param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
578
+ that will be passed as-is in the request. This value can be
579
+ overwritten in methods.
580
+ :param expected_status_codes: List of HTTP status codes expected for this request.
581
+ :param raise_scim_errors: If :data:`True` and the server returned an
582
+ :class:`~scim2_models.Error` object during a request, a
583
+ :class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
584
+ :param kwargs: Additional request parameters.
585
+ :return: The prepared request payload.
586
+ """
587
+ req = RequestPayload(
588
+ expected_status_codes=expected_status_codes,
589
+ request_kwargs=kwargs,
590
+ )
591
+
592
+ if check_request_payload is None:
593
+ check_request_payload = self.check_request_payload
594
+
595
+ self._check_resource_model(resource_model)
596
+
597
+ if not check_request_payload:
598
+ req.payload = patch_op
599
+ req.url = req.request_kwargs.pop(
600
+ "url", f"{self.resource_endpoint(resource_model)}/{id}"
601
+ )
602
+
603
+ else:
604
+ if isinstance(patch_op, dict):
605
+ req.payload = patch_op
606
+ else:
607
+ try:
608
+ req.payload = patch_op.model_dump(
609
+ scim_ctx=Context.RESOURCE_PATCH_REQUEST
610
+ )
611
+ except ValidationError as exc:
612
+ scim_validation_exc = RequestPayloadValidationError(source=patch_op)
613
+ if sys.version_info >= (3, 11): # pragma: no cover
614
+ scim_validation_exc.add_note(str(exc))
615
+ raise scim_validation_exc from exc
616
+
617
+ req.url = req.request_kwargs.pop(
618
+ "url", f"{self.resource_endpoint(resource_model)}/{id}"
619
+ )
620
+
621
+ req.expected_types = [resource_model]
622
+ return req
623
+
537
624
  def modify(
538
- self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
539
- ) -> Optional[Union[AnyResource, dict]]:
625
+ self,
626
+ resource_model: type[ResourceT],
627
+ id: str,
628
+ patch_op: Union[PatchOp[ResourceT], dict],
629
+ **kwargs,
630
+ ) -> Optional[Union[ResourceT, Error, dict]]:
540
631
  raise NotImplementedError()
541
632
 
542
633
  def build_resource_models(
@@ -552,13 +643,13 @@ class SCIMClient:
552
643
  for schema, resource_type in resource_types_by_schema.items():
553
644
  schema_obj = schema_objs_by_schema[schema]
554
645
  model = Resource.from_schema(schema_obj)
555
- extensions = []
646
+ extensions: tuple[type[Extension], ...] = ()
556
647
  for ext_schema in resource_type.schema_extensions or []:
557
648
  schema_obj = schema_objs_by_schema[ext_schema.schema_]
558
649
  extension = Extension.from_schema(schema_obj)
559
- extensions.append(extension)
650
+ extensions = extensions + (extension,)
560
651
  if extensions:
561
- model = model[Union[tuple(extensions)]]
652
+ model = model[Union[extensions]]
562
653
  resource_models.append(model)
563
654
 
564
655
  return tuple(resource_models)
@@ -615,7 +706,7 @@ class BaseSyncSCIMClient(SCIMClient):
615
706
 
616
707
  def query(
617
708
  self,
618
- resource_model: Optional[type[Resource]] = None,
709
+ resource_model: Optional[type[AnyResource]] = None,
619
710
  id: Optional[str] = None,
620
711
  search_request: Optional[Union[SearchRequest, dict]] = None,
621
712
  check_request_payload: Optional[bool] = None,
@@ -820,6 +911,62 @@ class BaseSyncSCIMClient(SCIMClient):
820
911
  """
821
912
  raise NotImplementedError()
822
913
 
914
+ def modify(
915
+ self,
916
+ resource_model: type[ResourceT],
917
+ id: str,
918
+ patch_op: Union[PatchOp[ResourceT], dict],
919
+ check_request_payload: Optional[bool] = None,
920
+ check_response_payload: Optional[bool] = None,
921
+ expected_status_codes: Optional[
922
+ list[int]
923
+ ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
924
+ raise_scim_errors: Optional[bool] = None,
925
+ **kwargs,
926
+ ) -> Optional[Union[ResourceT, Error, dict]]:
927
+ """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
928
+
929
+ :param resource_model: The type of the resource to modify.
930
+ :param id: The id of the resource to modify.
931
+ :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
932
+ Must be parameterized with the same resource type as ``resource_model``
933
+ (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
934
+ :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
935
+ :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
936
+ :param expected_status_codes: The list of expected status codes form the response.
937
+ If :data:`None` any status code is accepted.
938
+ :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
939
+ :param kwargs: Additional parameters passed to the underlying
940
+ HTTP request library.
941
+
942
+ :return:
943
+ - An :class:`~scim2_models.Error` object in case of error.
944
+ - The updated object as returned by the server in case of success if status code is 200.
945
+ - :data:`None` in case of success if status code is 204.
946
+
947
+ :usage:
948
+
949
+ .. code-block:: python
950
+ :caption: Modification of a `User` resource
951
+
952
+ from scim2_models import User, PatchOp, PatchOperation
953
+
954
+ operation = PatchOperation(
955
+ op="replace", path="displayName", value="New Display Name"
956
+ )
957
+ patch_op = PatchOp[User](operations=[operation])
958
+ response = scim.modify(User, "my-user-id", patch_op)
959
+ # 'response' may be a User, None, or an Error object
960
+
961
+ .. tip::
962
+
963
+ Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
964
+ and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
965
+ which value will excluded from the request payload, and which values are expected in
966
+ the response payload.
967
+ """
968
+ raise NotImplementedError()
969
+
823
970
  def discover(self, schemas=True, resource_types=True, service_provider_config=True):
824
971
  """Dynamically discover the server configuration objects.
825
972
 
@@ -1097,6 +1244,62 @@ class BaseAsyncSCIMClient(SCIMClient):
1097
1244
  """
1098
1245
  raise NotImplementedError()
1099
1246
 
1247
+ async def modify(
1248
+ self,
1249
+ resource_model: type[ResourceT],
1250
+ id: str,
1251
+ patch_op: Union[PatchOp[ResourceT], dict],
1252
+ check_request_payload: Optional[bool] = None,
1253
+ check_response_payload: Optional[bool] = None,
1254
+ expected_status_codes: Optional[
1255
+ list[int]
1256
+ ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
1257
+ raise_scim_errors: Optional[bool] = None,
1258
+ **kwargs,
1259
+ ) -> Optional[Union[ResourceT, Error, dict]]:
1260
+ """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
1261
+
1262
+ :param resource_model: The type of the resource to modify.
1263
+ :param id: The id of the resource to modify.
1264
+ :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
1265
+ Must be parameterized with the same resource type as ``resource_model``
1266
+ (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
1267
+ :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
1268
+ :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
1269
+ :param expected_status_codes: The list of expected status codes form the response.
1270
+ If :data:`None` any status code is accepted.
1271
+ :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
1272
+ :param kwargs: Additional parameters passed to the underlying
1273
+ HTTP request library.
1274
+
1275
+ :return:
1276
+ - An :class:`~scim2_models.Error` object in case of error.
1277
+ - The updated object as returned by the server in case of success if status code is 200.
1278
+ - :data:`None` in case of success if status code is 204.
1279
+
1280
+ :usage:
1281
+
1282
+ .. code-block:: python
1283
+ :caption: Modification of a `User` resource
1284
+
1285
+ from scim2_models import User, PatchOp, PatchOperation
1286
+
1287
+ operation = PatchOperation(
1288
+ op="replace", path="displayName", value="New Display Name"
1289
+ )
1290
+ patch_op = PatchOp[User](operations=[operation])
1291
+ response = await scim.modify(User, "my-user-id", patch_op)
1292
+ # 'response' may be a User, None, or an Error object
1293
+
1294
+ .. tip::
1295
+
1296
+ Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
1297
+ and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
1298
+ which value will excluded from the request payload, and which values are expected in
1299
+ the response payload.
1300
+ """
1301
+ raise NotImplementedError()
1302
+
1100
1303
  async def discover(
1101
1304
  self, schemas=True, resource_types=True, service_provider_config=True
1102
1305
  ):
@@ -2,6 +2,7 @@ import json
2
2
  import sys
3
3
  from contextlib import contextmanager
4
4
  from typing import Optional
5
+ from typing import TypeVar
5
6
  from typing import Union
6
7
 
7
8
  from httpx import Client
@@ -11,6 +12,7 @@ from scim2_models import AnyResource
11
12
  from scim2_models import Context
12
13
  from scim2_models import Error
13
14
  from scim2_models import ListResponse
15
+ from scim2_models import PatchOp
14
16
  from scim2_models import Resource
15
17
  from scim2_models import SearchRequest
16
18
 
@@ -20,6 +22,8 @@ from scim2_client.errors import RequestNetworkError
20
22
  from scim2_client.errors import SCIMClientError
21
23
  from scim2_client.errors import UnexpectedContentFormat
22
24
 
25
+ ResourceT = TypeVar("ResourceT", bound=Resource)
26
+
23
27
 
24
28
  @contextmanager
25
29
  def handle_request_error(payload=None):
@@ -77,11 +81,10 @@ class SyncSCIMClient(BaseSyncSCIMClient):
77
81
  raise_scim_errors: Optional[bool] = None,
78
82
  **kwargs,
79
83
  ) -> Union[AnyResource, Error, dict]:
80
- req = self.prepare_create_request(
84
+ req = self._prepare_create_request(
81
85
  resource=resource,
82
86
  check_request_payload=check_request_payload,
83
87
  expected_status_codes=expected_status_codes,
84
- raise_scim_errors=raise_scim_errors,
85
88
  **kwargs,
86
89
  )
87
90
 
@@ -102,7 +105,7 @@ class SyncSCIMClient(BaseSyncSCIMClient):
102
105
 
103
106
  def query(
104
107
  self,
105
- resource_model: Optional[type[Resource]] = None,
108
+ resource_model: Optional[type[AnyResource]] = None,
106
109
  id: Optional[str] = None,
107
110
  search_request: Optional[Union[SearchRequest, dict]] = None,
108
111
  check_request_payload: Optional[bool] = None,
@@ -112,14 +115,13 @@ class SyncSCIMClient(BaseSyncSCIMClient):
112
115
  ] = BaseSyncSCIMClient.QUERY_RESPONSE_STATUS_CODES,
113
116
  raise_scim_errors: Optional[bool] = None,
114
117
  **kwargs,
115
- ):
116
- req = self.prepare_query_request(
118
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
119
+ req = self._prepare_query_request(
117
120
  resource_model=resource_model,
118
121
  id=id,
119
122
  search_request=search_request,
120
123
  check_request_payload=check_request_payload,
121
124
  expected_status_codes=expected_status_codes,
122
- raise_scim_errors=raise_scim_errors,
123
125
  **kwargs,
124
126
  )
125
127
 
@@ -151,11 +153,10 @@ class SyncSCIMClient(BaseSyncSCIMClient):
151
153
  raise_scim_errors: Optional[bool] = None,
152
154
  **kwargs,
153
155
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
154
- req = self.prepare_search_request(
156
+ req = self._prepare_search_request(
155
157
  search_request=search_request,
156
158
  check_request_payload=check_request_payload,
157
159
  expected_status_codes=expected_status_codes,
158
- raise_scim_errors=raise_scim_errors,
159
160
  **kwargs,
160
161
  )
161
162
 
@@ -185,11 +186,10 @@ class SyncSCIMClient(BaseSyncSCIMClient):
185
186
  raise_scim_errors: Optional[bool] = None,
186
187
  **kwargs,
187
188
  ) -> Optional[Union[Error, dict]]:
188
- req = self.prepare_delete_request(
189
+ req = self._prepare_delete_request(
189
190
  resource_model=resource_model,
190
191
  id=id,
191
192
  expected_status_codes=expected_status_codes,
192
- raise_scim_errors=raise_scim_errors,
193
193
  **kwargs,
194
194
  )
195
195
 
@@ -217,11 +217,10 @@ class SyncSCIMClient(BaseSyncSCIMClient):
217
217
  raise_scim_errors: Optional[bool] = None,
218
218
  **kwargs,
219
219
  ) -> Union[AnyResource, Error, dict]:
220
- req = self.prepare_replace_request(
220
+ req = self._prepare_replace_request(
221
221
  resource=resource,
222
222
  check_request_payload=check_request_payload,
223
223
  expected_status_codes=expected_status_codes,
224
- raise_scim_errors=raise_scim_errors,
225
224
  **kwargs,
226
225
  )
227
226
 
@@ -240,6 +239,45 @@ class SyncSCIMClient(BaseSyncSCIMClient):
240
239
  scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
241
240
  )
242
241
 
242
+ def modify(
243
+ self,
244
+ resource_model: type[ResourceT],
245
+ id: str,
246
+ patch_op: Union[PatchOp[ResourceT], dict],
247
+ check_request_payload: Optional[bool] = None,
248
+ check_response_payload: Optional[bool] = None,
249
+ expected_status_codes: Optional[
250
+ list[int]
251
+ ] = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES,
252
+ raise_scim_errors: Optional[bool] = None,
253
+ **kwargs,
254
+ ) -> Optional[Union[ResourceT, Error, dict]]:
255
+ req = self._prepare_patch_request(
256
+ resource_model=resource_model,
257
+ id=id,
258
+ patch_op=patch_op,
259
+ check_request_payload=check_request_payload,
260
+ expected_status_codes=expected_status_codes,
261
+ **kwargs,
262
+ )
263
+
264
+ with handle_request_error(req.payload):
265
+ response = self.client.patch(
266
+ req.url, json=req.payload, **req.request_kwargs
267
+ )
268
+
269
+ with handle_response_error(response):
270
+ return self.check_response(
271
+ payload=response.json() if response.text else None,
272
+ status_code=response.status_code,
273
+ headers=response.headers,
274
+ expected_status_codes=req.expected_status_codes,
275
+ expected_types=req.expected_types,
276
+ check_response_payload=check_response_payload,
277
+ raise_scim_errors=raise_scim_errors,
278
+ scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
279
+ )
280
+
243
281
 
244
282
  class AsyncSCIMClient(BaseAsyncSCIMClient):
245
283
  """Perform SCIM requests over the network and validate responses.
@@ -273,11 +311,10 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
273
311
  raise_scim_errors: Optional[bool] = None,
274
312
  **kwargs,
275
313
  ) -> Union[AnyResource, Error, dict]:
276
- req = self.prepare_create_request(
314
+ req = self._prepare_create_request(
277
315
  resource=resource,
278
316
  check_request_payload=check_request_payload,
279
317
  expected_status_codes=expected_status_codes,
280
- raise_scim_errors=raise_scim_errors,
281
318
  **kwargs,
282
319
  )
283
320
 
@@ -310,14 +347,13 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
310
347
  ] = BaseAsyncSCIMClient.QUERY_RESPONSE_STATUS_CODES,
311
348
  raise_scim_errors: Optional[bool] = None,
312
349
  **kwargs,
313
- ):
314
- req = self.prepare_query_request(
350
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
351
+ req = self._prepare_query_request(
315
352
  resource_model=resource_model,
316
353
  id=id,
317
354
  search_request=search_request,
318
355
  check_request_payload=check_request_payload,
319
356
  expected_status_codes=expected_status_codes,
320
- raise_scim_errors=raise_scim_errors,
321
357
  **kwargs,
322
358
  )
323
359
 
@@ -349,11 +385,10 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
349
385
  raise_scim_errors: Optional[bool] = None,
350
386
  **kwargs,
351
387
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
352
- req = self.prepare_search_request(
388
+ req = self._prepare_search_request(
353
389
  search_request=search_request,
354
390
  check_request_payload=check_request_payload,
355
391
  expected_status_codes=expected_status_codes,
356
- raise_scim_errors=raise_scim_errors,
357
392
  **kwargs,
358
393
  )
359
394
 
@@ -385,11 +420,10 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
385
420
  raise_scim_errors: Optional[bool] = None,
386
421
  **kwargs,
387
422
  ) -> Optional[Union[Error, dict]]:
388
- req = self.prepare_delete_request(
423
+ req = self._prepare_delete_request(
389
424
  resource_model=resource_model,
390
425
  id=id,
391
426
  expected_status_codes=expected_status_codes,
392
- raise_scim_errors=raise_scim_errors,
393
427
  **kwargs,
394
428
  )
395
429
 
@@ -417,11 +451,10 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
417
451
  raise_scim_errors: Optional[bool] = None,
418
452
  **kwargs,
419
453
  ) -> Union[AnyResource, Error, dict]:
420
- req = self.prepare_replace_request(
454
+ req = self._prepare_replace_request(
421
455
  resource=resource,
422
456
  check_request_payload=check_request_payload,
423
457
  expected_status_codes=expected_status_codes,
424
- raise_scim_errors=raise_scim_errors,
425
458
  **kwargs,
426
459
  )
427
460
 
@@ -441,3 +474,42 @@ class AsyncSCIMClient(BaseAsyncSCIMClient):
441
474
  raise_scim_errors=raise_scim_errors,
442
475
  scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
443
476
  )
477
+
478
+ async def modify(
479
+ self,
480
+ resource_model: type[ResourceT],
481
+ id: str,
482
+ patch_op: Union[PatchOp[ResourceT], dict],
483
+ check_request_payload: Optional[bool] = None,
484
+ check_response_payload: Optional[bool] = None,
485
+ expected_status_codes: Optional[
486
+ list[int]
487
+ ] = BaseAsyncSCIMClient.PATCH_RESPONSE_STATUS_CODES,
488
+ raise_scim_errors: Optional[bool] = None,
489
+ **kwargs,
490
+ ) -> Optional[Union[ResourceT, Error, dict]]:
491
+ req = self._prepare_patch_request(
492
+ resource_model=resource_model,
493
+ id=id,
494
+ patch_op=patch_op,
495
+ check_request_payload=check_request_payload,
496
+ expected_status_codes=expected_status_codes,
497
+ **kwargs,
498
+ )
499
+
500
+ with handle_request_error(req.payload):
501
+ response = await self.client.patch(
502
+ req.url, json=req.payload, **req.request_kwargs
503
+ )
504
+
505
+ with handle_response_error(response):
506
+ return self.check_response(
507
+ payload=response.json() if response.text else None,
508
+ status_code=response.status_code,
509
+ headers=response.headers,
510
+ expected_status_codes=req.expected_status_codes,
511
+ expected_types=req.expected_types,
512
+ check_response_payload=check_response_payload,
513
+ raise_scim_errors=raise_scim_errors,
514
+ scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
515
+ )
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  from contextlib import contextmanager
3
3
  from typing import Optional
4
+ from typing import TypeVar
4
5
  from typing import Union
5
6
  from urllib.parse import urlencode
6
7
 
@@ -8,6 +9,7 @@ from scim2_models import AnyResource
8
9
  from scim2_models import Context
9
10
  from scim2_models import Error
10
11
  from scim2_models import ListResponse
12
+ from scim2_models import PatchOp
11
13
  from scim2_models import Resource
12
14
  from scim2_models import SearchRequest
13
15
  from werkzeug.test import Client
@@ -16,6 +18,8 @@ from scim2_client.client import BaseSyncSCIMClient
16
18
  from scim2_client.errors import SCIMClientError
17
19
  from scim2_client.errors import UnexpectedContentFormat
18
20
 
21
+ ResourceT = TypeVar("ResourceT", bound=Resource)
22
+
19
23
 
20
24
  @contextmanager
21
25
  def handle_response_error(response):
@@ -86,7 +90,7 @@ class TestSCIMClient(BaseSyncSCIMClient):
86
90
  self.scim_prefix = scim_prefix
87
91
  self.environ = environ or {}
88
92
 
89
- def make_url(self, url: Optional[str]) -> str:
93
+ def _make_url(self, url: Optional[str]) -> str:
90
94
  url = url or ""
91
95
  prefix = (
92
96
  self.scim_prefix[:-1]
@@ -110,16 +114,17 @@ class TestSCIMClient(BaseSyncSCIMClient):
110
114
  raise_scim_errors: Optional[bool] = None,
111
115
  **kwargs,
112
116
  ) -> Union[AnyResource, Error, dict]:
113
- req = self.prepare_create_request(
117
+ req = self._prepare_create_request(
114
118
  resource=resource,
115
119
  check_request_payload=check_request_payload,
116
120
  expected_status_codes=expected_status_codes,
117
- raise_scim_errors=raise_scim_errors,
118
121
  **kwargs,
119
122
  )
120
123
 
121
124
  environ = {**self.environ, **req.request_kwargs}
122
- response = self.client.post(self.make_url(req.url), json=req.payload, **environ)
125
+ response = self.client.post(
126
+ self._make_url(req.url), json=req.payload, **environ
127
+ )
123
128
 
124
129
  with handle_response_error(req.payload):
125
130
  return self.check_response(
@@ -135,7 +140,7 @@ class TestSCIMClient(BaseSyncSCIMClient):
135
140
 
136
141
  def query(
137
142
  self,
138
- resource_model: Optional[type[Resource]] = None,
143
+ resource_model: Optional[type[AnyResource]] = None,
139
144
  id: Optional[str] = None,
140
145
  search_request: Optional[Union[SearchRequest, dict]] = None,
141
146
  check_request_payload: Optional[bool] = None,
@@ -145,21 +150,20 @@ class TestSCIMClient(BaseSyncSCIMClient):
145
150
  ] = BaseSyncSCIMClient.QUERY_RESPONSE_STATUS_CODES,
146
151
  raise_scim_errors: Optional[bool] = None,
147
152
  **kwargs,
148
- ):
149
- req = self.prepare_query_request(
153
+ ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
154
+ req = self._prepare_query_request(
150
155
  resource_model=resource_model,
151
156
  id=id,
152
157
  search_request=search_request,
153
158
  check_request_payload=check_request_payload,
154
159
  expected_status_codes=expected_status_codes,
155
- raise_scim_errors=raise_scim_errors,
156
160
  **kwargs,
157
161
  )
158
162
 
159
163
  query_string = urlencode(req.payload, doseq=False) if req.payload else None
160
164
  environ = {**self.environ, **req.request_kwargs}
161
165
  response = self.client.get(
162
- self.make_url(req.url), query_string=query_string, **environ
166
+ self._make_url(req.url), query_string=query_string, **environ
163
167
  )
164
168
 
165
169
  with handle_response_error(req.payload):
@@ -185,16 +189,17 @@ class TestSCIMClient(BaseSyncSCIMClient):
185
189
  raise_scim_errors: Optional[bool] = None,
186
190
  **kwargs,
187
191
  ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]:
188
- req = self.prepare_search_request(
192
+ req = self._prepare_search_request(
189
193
  search_request=search_request,
190
194
  check_request_payload=check_request_payload,
191
195
  expected_status_codes=expected_status_codes,
192
- raise_scim_errors=raise_scim_errors,
193
196
  **kwargs,
194
197
  )
195
198
 
196
199
  environ = {**self.environ, **req.request_kwargs}
197
- response = self.client.post(self.make_url(req.url), json=req.payload, **environ)
200
+ response = self.client.post(
201
+ self._make_url(req.url), json=req.payload, **environ
202
+ )
198
203
 
199
204
  with handle_response_error(response):
200
205
  return self.check_response(
@@ -219,16 +224,15 @@ class TestSCIMClient(BaseSyncSCIMClient):
219
224
  raise_scim_errors: Optional[bool] = None,
220
225
  **kwargs,
221
226
  ) -> Optional[Union[Error, dict]]:
222
- req = self.prepare_delete_request(
227
+ req = self._prepare_delete_request(
223
228
  resource_model=resource_model,
224
229
  id=id,
225
230
  expected_status_codes=expected_status_codes,
226
- raise_scim_errors=raise_scim_errors,
227
231
  **kwargs,
228
232
  )
229
233
 
230
234
  environ = {**self.environ, **req.request_kwargs}
231
- response = self.client.delete(self.make_url(req.url), **environ)
235
+ response = self.client.delete(self._make_url(req.url), **environ)
232
236
 
233
237
  with handle_response_error(response):
234
238
  return self.check_response(
@@ -251,16 +255,15 @@ class TestSCIMClient(BaseSyncSCIMClient):
251
255
  raise_scim_errors: Optional[bool] = None,
252
256
  **kwargs,
253
257
  ) -> Union[AnyResource, Error, dict]:
254
- req = self.prepare_replace_request(
258
+ req = self._prepare_replace_request(
255
259
  resource=resource,
256
260
  check_request_payload=check_request_payload,
257
261
  expected_status_codes=expected_status_codes,
258
- raise_scim_errors=raise_scim_errors,
259
262
  **kwargs,
260
263
  )
261
264
 
262
265
  environ = {**self.environ, **req.request_kwargs}
263
- response = self.client.put(self.make_url(req.url), json=req.payload, **environ)
266
+ response = self.client.put(self._make_url(req.url), json=req.payload, **environ)
264
267
 
265
268
  with handle_response_error(response):
266
269
  return self.check_response(
@@ -273,3 +276,42 @@ class TestSCIMClient(BaseSyncSCIMClient):
273
276
  raise_scim_errors=raise_scim_errors,
274
277
  scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
275
278
  )
279
+
280
+ def modify(
281
+ self,
282
+ resource_model: type[ResourceT],
283
+ id: str,
284
+ patch_op: Union[PatchOp[ResourceT], dict],
285
+ check_request_payload: Optional[bool] = None,
286
+ check_response_payload: Optional[bool] = None,
287
+ expected_status_codes: Optional[
288
+ list[int]
289
+ ] = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES,
290
+ raise_scim_errors: Optional[bool] = None,
291
+ **kwargs,
292
+ ) -> Optional[Union[ResourceT, Error, dict]]:
293
+ req = self._prepare_patch_request(
294
+ resource_model=resource_model,
295
+ id=id,
296
+ patch_op=patch_op,
297
+ check_request_payload=check_request_payload,
298
+ expected_status_codes=expected_status_codes,
299
+ **kwargs,
300
+ )
301
+
302
+ environ = {**self.environ, **req.request_kwargs}
303
+ response = self.client.patch(
304
+ self._make_url(req.url), json=req.payload, **environ
305
+ )
306
+
307
+ with handle_response_error(response):
308
+ return self.check_response(
309
+ payload=response.json if response.text else None,
310
+ status_code=response.status_code,
311
+ headers=response.headers,
312
+ expected_status_codes=req.expected_status_codes,
313
+ expected_types=req.expected_types,
314
+ check_response_payload=check_response_payload,
315
+ raise_scim_errors=raise_scim_errors,
316
+ scim_ctx=Context.RESOURCE_PATCH_RESPONSE,
317
+ )
scim2_client/errors.py CHANGED
@@ -9,12 +9,14 @@ class SCIMClientError(Exception):
9
9
  caused the exception.
10
10
  """
11
11
 
12
- def __init__(self, message: str, source: Any = None, *args, **kwargs):
12
+ def __init__(
13
+ self, message: str, source: Any = None, *args: Any, **kwargs: Any
14
+ ) -> None:
13
15
  self.message = message
14
16
  self.source = source
15
17
  super().__init__(*args, **kwargs)
16
18
 
17
- def __str__(self):
19
+ def __str__(self) -> str:
18
20
  return self.message or "UNKNOWN"
19
21
 
20
22
 
@@ -29,7 +31,7 @@ class RequestNetworkError(SCIMRequestError):
29
31
  The original :class:`~httpx.RequestError` is available with :attr:`~BaseException.__cause__`.
30
32
  """
31
33
 
32
- def __init__(self, *args, **kwargs):
34
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
33
35
  message = kwargs.pop("message", "Network error happened during request")
34
36
  super().__init__(message, *args, **kwargs)
35
37
 
@@ -54,7 +56,7 @@ class RequestPayloadValidationError(SCIMRequestError):
54
56
  print("Original validation error cause", exc.__cause__)
55
57
  """
56
58
 
57
- def __init__(self, *args, **kwargs):
59
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
58
60
  message = kwargs.pop("message", "Server request payload validation error")
59
61
  super().__init__(message, *args, **kwargs)
60
62
 
@@ -69,7 +71,7 @@ class SCIMResponseErrorObject(SCIMResponseError):
69
71
  Those errors are only raised when the :code:`raise_scim_errors` parameter is :data:`True`.
70
72
  """
71
73
 
72
- def __init__(self, obj, *args, **kwargs):
74
+ def __init__(self, obj: Any, *args: Any, **kwargs: Any) -> None:
73
75
  message = kwargs.pop(
74
76
  "message", f"The server returned a SCIM Error object: {obj}"
75
77
  )
@@ -79,7 +81,7 @@ class SCIMResponseErrorObject(SCIMResponseError):
79
81
  class UnexpectedStatusCode(SCIMResponseError):
80
82
  """Error raised when a server returned an unexpected status code for a given :class:`~scim2_models.Context`."""
81
83
 
82
- def __init__(self, status_code: int, *args, **kwargs):
84
+ def __init__(self, status_code: int, *args: Any, **kwargs: Any) -> None:
83
85
  message = kwargs.pop(
84
86
  "message", f"Unexpected response status code: {status_code}"
85
87
  )
@@ -89,7 +91,7 @@ class UnexpectedStatusCode(SCIMResponseError):
89
91
  class UnexpectedContentType(SCIMResponseError):
90
92
  """Error raised when a server returned an unexpected `Content-Type` header in a response."""
91
93
 
92
- def __init__(self, content_type, *args, **kwargs):
94
+ def __init__(self, content_type: str, *args: Any, **kwargs: Any) -> None:
93
95
  message = kwargs.pop("message", f"Unexpected content type: {content_type}")
94
96
  super().__init__(message, *args, **kwargs)
95
97
 
@@ -97,7 +99,7 @@ class UnexpectedContentType(SCIMResponseError):
97
99
  class UnexpectedContentFormat(SCIMResponseError):
98
100
  """Error raised when a server returned a response in a non-JSON format."""
99
101
 
100
- def __init__(self, *args, **kwargs):
102
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
101
103
  message = kwargs.pop("message", "Unexpected response content format")
102
104
  super().__init__(message, *args, **kwargs)
103
105
 
@@ -117,6 +119,6 @@ class ResponsePayloadValidationError(SCIMResponseError):
117
119
  print("Original validation error cause", exc.__cause__)
118
120
  """
119
121
 
120
- def __init__(self, *args, **kwargs):
122
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
121
123
  message = kwargs.pop("message", "Server response payload validation error")
122
124
  super().__init__(message, *args, **kwargs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scim2-client
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Pythonically build SCIM requests and parse SCIM responses
5
5
  Project-URL: documentation, https://scim2-client.readthedocs.io
6
6
  Project-URL: repository, https://github.com/python-scim/scim2-client
@@ -223,7 +223,7 @@ Classifier: Programming Language :: Python :: 3.12
223
223
  Classifier: Programming Language :: Python :: 3.13
224
224
  Classifier: Programming Language :: Python :: Implementation :: CPython
225
225
  Requires-Python: >=3.9
226
- Requires-Dist: scim2-models>=0.2.0
226
+ Requires-Dist: scim2-models>=0.4.1
227
227
  Provides-Extra: httpx
228
228
  Requires-Dist: httpx>=0.28.0; extra == 'httpx'
229
229
  Provides-Extra: werkzeug
@@ -0,0 +1,11 @@
1
+ scim2_client/__init__.py,sha256=l0pyBLiTpFA68ao98PqQLT_Xx0mw8BHumwrIHYCWa_M,845
2
+ scim2_client/client.py,sha256=jtL0Lhy8USPECiAwVbD5grIVePFIIdfibVu0_5EQISQ,55067
3
+ scim2_client/errors.py,sha256=LTTrjwTImKcPmeEcd_WqSphaGNf_2tlRA4V7UhndAE8,4572
4
+ scim2_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ scim2_client/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ scim2_client/engines/httpx.py,sha256=vpMa6_1q8RsyZo1hCm8FOt5htbgiEW58LZ5EwkMu82s,20535
7
+ scim2_client/engines/werkzeug.py,sha256=mszoQSneEH2SsewcG7Pq6hW-zFIeyreObFuO2K89ssc,12277
8
+ scim2_client-0.6.1.dist-info/METADATA,sha256=SVVX7EcdbqP_9jF9yS1CB2dsNrGidGKNctJAQ0U81Ws,16986
9
+ scim2_client-0.6.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ scim2_client-0.6.1.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ scim2_client-0.6.1.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- scim2_client/__init__.py,sha256=l0pyBLiTpFA68ao98PqQLT_Xx0mw8BHumwrIHYCWa_M,845
2
- scim2_client/client.py,sha256=jSj_g8qjpCFF1sKd-STJNPjfdHN_zjGAkYBes9CveOY,46374
3
- scim2_client/errors.py,sha256=FVmRXsaZLn1VZhJ3dSDs4IqycuU92AEun9JWWMseVO8,4397
4
- scim2_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- scim2_client/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- scim2_client/engines/httpx.py,sha256=L39ZZHjqe43uIQpgh_r_maqM2gkf3_Rk6d8MeNe57gk,17781
7
- scim2_client/engines/werkzeug.py,sha256=DmUBWytoWPehcrgBpGzlRJLCT7mrAW_VWSQ6BHylwCo,10800
8
- scim2_client-0.5.2.dist-info/METADATA,sha256=hmKozbJhq6eZxe_rFfspw-hHsD9H1pQmfsHMm_CTTdk,16986
9
- scim2_client-0.5.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
- scim2_client-0.5.2.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
- scim2_client-0.5.2.dist-info/RECORD,,