scim2-client 0.5.1__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.
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,7 +202,7 @@ 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
208
  if (
@@ -299,11 +322,15 @@ class SCIMClient:
299
322
  if not expected_types:
300
323
  return response_payload
301
324
 
325
+ # For no-content responses, return None directly
326
+ if response_payload is None:
327
+ return None
328
+
302
329
  actual_type = Resource.get_by_payload(
303
330
  expected_types, response_payload, with_extensions=False
304
331
  )
305
332
 
306
- if response_payload and not actual_type:
333
+ if not actual_type:
307
334
  expected = ", ".join([type_.__name__ for type_ in expected_types])
308
335
  try:
309
336
  schema = ", ".join(response_payload["schemas"])
@@ -323,12 +350,11 @@ class SCIMClient:
323
350
  scim_exc.add_note(str(exc))
324
351
  raise scim_exc from exc
325
352
 
326
- def prepare_create_request(
353
+ def _prepare_create_request(
327
354
  self,
328
355
  resource: Union[AnyResource, dict],
329
356
  check_request_payload: Optional[bool] = None,
330
357
  expected_status_codes: Optional[list[int]] = None,
331
- raise_scim_errors: Optional[bool] = None,
332
358
  **kwargs,
333
359
  ) -> RequestPayload:
334
360
  req = RequestPayload(
@@ -361,7 +387,7 @@ class SCIMClient:
361
387
  scim_validation_exc.add_note(str(exc))
362
388
  raise scim_validation_exc from exc
363
389
 
364
- self.check_resource_model(resource_model, resource)
390
+ self._check_resource_model(resource_model, resource)
365
391
  req.expected_types = [resource.__class__]
366
392
  req.url = req.request_kwargs.pop(
367
393
  "url", self.resource_endpoint(resource_model)
@@ -372,14 +398,13 @@ class SCIMClient:
372
398
 
373
399
  return req
374
400
 
375
- def prepare_query_request(
401
+ def _prepare_query_request(
376
402
  self,
377
403
  resource_model: Optional[type[Resource]] = None,
378
404
  id: Optional[str] = None,
379
405
  search_request: Optional[Union[SearchRequest, dict]] = None,
380
406
  check_request_payload: Optional[bool] = None,
381
407
  expected_status_codes: Optional[list[int]] = None,
382
- raise_scim_errors: Optional[bool] = None,
383
408
  **kwargs,
384
409
  ) -> RequestPayload:
385
410
  req = RequestPayload(
@@ -391,7 +416,7 @@ class SCIMClient:
391
416
  check_request_payload = self.check_request_payload
392
417
 
393
418
  if resource_model and check_request_payload:
394
- self.check_resource_model(resource_model)
419
+ self._check_resource_model(resource_model)
395
420
 
396
421
  payload: Optional[SearchRequest]
397
422
  if not check_request_payload:
@@ -429,12 +454,11 @@ class SCIMClient:
429
454
 
430
455
  return req
431
456
 
432
- def prepare_search_request(
457
+ def _prepare_search_request(
433
458
  self,
434
459
  search_request: Optional[SearchRequest] = None,
435
460
  check_request_payload: Optional[bool] = None,
436
461
  expected_status_codes: Optional[list[int]] = None,
437
- raise_scim_errors: Optional[bool] = None,
438
462
  **kwargs,
439
463
  ) -> RequestPayload:
440
464
  req = RequestPayload(
@@ -461,12 +485,11 @@ class SCIMClient:
461
485
  req.expected_types = [ListResponse[Union[self.resource_models]]]
462
486
  return req
463
487
 
464
- def prepare_delete_request(
488
+ def _prepare_delete_request(
465
489
  self,
466
490
  resource_model: type,
467
491
  id: str,
468
492
  expected_status_codes: Optional[list[int]] = None,
469
- raise_scim_errors: Optional[bool] = None,
470
493
  **kwargs,
471
494
  ) -> RequestPayload:
472
495
  req = RequestPayload(
@@ -474,17 +497,16 @@ class SCIMClient:
474
497
  request_kwargs=kwargs,
475
498
  )
476
499
 
477
- self.check_resource_model(resource_model)
500
+ self._check_resource_model(resource_model)
478
501
  delete_url = self.resource_endpoint(resource_model) + f"/{id}"
479
502
  req.url = req.request_kwargs.pop("url", delete_url)
480
503
  return req
481
504
 
482
- def prepare_replace_request(
505
+ def _prepare_replace_request(
483
506
  self,
484
507
  resource: Union[AnyResource, dict],
485
508
  check_request_payload: Optional[bool] = None,
486
509
  expected_status_codes: Optional[list[int]] = None,
487
- raise_scim_errors: Optional[bool] = None,
488
510
  **kwargs,
489
511
  ) -> RequestPayload:
490
512
  req = RequestPayload(
@@ -519,7 +541,7 @@ class SCIMClient:
519
541
  scim_validation_exc.add_note(str(exc))
520
542
  raise scim_validation_exc from exc
521
543
 
522
- self.check_resource_model(resource_model, resource)
544
+ self._check_resource_model(resource_model, resource)
523
545
 
524
546
  if not resource.id:
525
547
  raise SCIMRequestError("Resource must have an id", source=resource)
@@ -534,9 +556,75 @@ class SCIMClient:
534
556
 
535
557
  return req
536
558
 
559
+ def _prepare_patch_request(
560
+ self,
561
+ resource_model: type[ResourceT],
562
+ id: str,
563
+ patch_op: Union[PatchOp[ResourceT], dict],
564
+ check_request_payload: Optional[bool] = None,
565
+ expected_status_codes: Optional[list[int]] = None,
566
+ **kwargs,
567
+ ) -> RequestPayload:
568
+ """Prepare a PATCH request payload.
569
+
570
+ :param resource_model: The resource type to modify (e.g., User, Group).
571
+ :param id: The resource ID.
572
+ :param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
573
+ (e.g., PatchOp[User] when resource_model is User), or a dict representation.
574
+ :param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
575
+ that will be passed as-is in the request. This value can be
576
+ overwritten in methods.
577
+ :param expected_status_codes: List of HTTP status codes expected for this request.
578
+ :param raise_scim_errors: If :data:`True` and the server returned an
579
+ :class:`~scim2_models.Error` object during a request, a
580
+ :class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
581
+ :param kwargs: Additional request parameters.
582
+ :return: The prepared request payload.
583
+ """
584
+ req = RequestPayload(
585
+ expected_status_codes=expected_status_codes,
586
+ request_kwargs=kwargs,
587
+ )
588
+
589
+ if check_request_payload is None:
590
+ check_request_payload = self.check_request_payload
591
+
592
+ self._check_resource_model(resource_model)
593
+
594
+ if not check_request_payload:
595
+ req.payload = patch_op
596
+ req.url = req.request_kwargs.pop(
597
+ "url", f"{self.resource_endpoint(resource_model)}/{id}"
598
+ )
599
+
600
+ else:
601
+ if isinstance(patch_op, dict):
602
+ req.payload = patch_op
603
+ else:
604
+ try:
605
+ req.payload = patch_op.model_dump(
606
+ scim_ctx=Context.RESOURCE_PATCH_REQUEST
607
+ )
608
+ except ValidationError as exc:
609
+ scim_validation_exc = RequestPayloadValidationError(source=patch_op)
610
+ if sys.version_info >= (3, 11): # pragma: no cover
611
+ scim_validation_exc.add_note(str(exc))
612
+ raise scim_validation_exc from exc
613
+
614
+ req.url = req.request_kwargs.pop(
615
+ "url", f"{self.resource_endpoint(resource_model)}/{id}"
616
+ )
617
+
618
+ req.expected_types = [resource_model]
619
+ return req
620
+
537
621
  def modify(
538
- self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
539
- ) -> Optional[Union[AnyResource, dict]]:
622
+ self,
623
+ resource_model: type[ResourceT],
624
+ id: str,
625
+ patch_op: Union[PatchOp[ResourceT], dict],
626
+ **kwargs,
627
+ ) -> Optional[Union[ResourceT, Error, dict]]:
540
628
  raise NotImplementedError()
541
629
 
542
630
  def build_resource_models(
@@ -558,7 +646,7 @@ class SCIMClient:
558
646
  extension = Extension.from_schema(schema_obj)
559
647
  extensions.append(extension)
560
648
  if extensions:
561
- model = model[tuple(extensions)]
649
+ model = model[Union[tuple(extensions)]]
562
650
  resource_models.append(model)
563
651
 
564
652
  return tuple(resource_models)
@@ -615,7 +703,7 @@ class BaseSyncSCIMClient(SCIMClient):
615
703
 
616
704
  def query(
617
705
  self,
618
- resource_model: Optional[type[Resource]] = None,
706
+ resource_model: Optional[type[AnyResource]] = None,
619
707
  id: Optional[str] = None,
620
708
  search_request: Optional[Union[SearchRequest, dict]] = None,
621
709
  check_request_payload: Optional[bool] = None,
@@ -820,6 +908,62 @@ class BaseSyncSCIMClient(SCIMClient):
820
908
  """
821
909
  raise NotImplementedError()
822
910
 
911
+ def modify(
912
+ self,
913
+ resource_model: type[ResourceT],
914
+ id: str,
915
+ patch_op: Union[PatchOp[ResourceT], dict],
916
+ check_request_payload: Optional[bool] = None,
917
+ check_response_payload: Optional[bool] = None,
918
+ expected_status_codes: Optional[
919
+ list[int]
920
+ ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
921
+ raise_scim_errors: Optional[bool] = None,
922
+ **kwargs,
923
+ ) -> Optional[Union[ResourceT, Error, dict]]:
924
+ """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
925
+
926
+ :param resource_model: The type of the resource to modify.
927
+ :param id: The id of the resource to modify.
928
+ :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
929
+ Must be parameterized with the same resource type as ``resource_model``
930
+ (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
931
+ :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
932
+ :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
933
+ :param expected_status_codes: The list of expected status codes form the response.
934
+ If :data:`None` any status code is accepted.
935
+ :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
936
+ :param kwargs: Additional parameters passed to the underlying
937
+ HTTP request library.
938
+
939
+ :return:
940
+ - An :class:`~scim2_models.Error` object in case of error.
941
+ - The updated object as returned by the server in case of success if status code is 200.
942
+ - :data:`None` in case of success if status code is 204.
943
+
944
+ :usage:
945
+
946
+ .. code-block:: python
947
+ :caption: Modification of a `User` resource
948
+
949
+ from scim2_models import User, PatchOp, PatchOperation
950
+
951
+ operation = PatchOperation(
952
+ op="replace", path="displayName", value="New Display Name"
953
+ )
954
+ patch_op = PatchOp[User](operations=[operation])
955
+ response = scim.modify(User, "my-user-id", patch_op)
956
+ # 'response' may be a User, None, or an Error object
957
+
958
+ .. tip::
959
+
960
+ Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
961
+ and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
962
+ which value will excluded from the request payload, and which values are expected in
963
+ the response payload.
964
+ """
965
+ raise NotImplementedError()
966
+
823
967
  def discover(self, schemas=True, resource_types=True, service_provider_config=True):
824
968
  """Dynamically discover the server configuration objects.
825
969
 
@@ -1097,6 +1241,62 @@ class BaseAsyncSCIMClient(SCIMClient):
1097
1241
  """
1098
1242
  raise NotImplementedError()
1099
1243
 
1244
+ async def modify(
1245
+ self,
1246
+ resource_model: type[ResourceT],
1247
+ id: str,
1248
+ patch_op: Union[PatchOp[ResourceT], dict],
1249
+ check_request_payload: Optional[bool] = None,
1250
+ check_response_payload: Optional[bool] = None,
1251
+ expected_status_codes: Optional[
1252
+ list[int]
1253
+ ] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
1254
+ raise_scim_errors: Optional[bool] = None,
1255
+ **kwargs,
1256
+ ) -> Optional[Union[ResourceT, Error, dict]]:
1257
+ """Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
1258
+
1259
+ :param resource_model: The type of the resource to modify.
1260
+ :param id: The id of the resource to modify.
1261
+ :param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
1262
+ Must be parameterized with the same resource type as ``resource_model``
1263
+ (e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
1264
+ :param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
1265
+ :param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
1266
+ :param expected_status_codes: The list of expected status codes form the response.
1267
+ If :data:`None` any status code is accepted.
1268
+ :param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
1269
+ :param kwargs: Additional parameters passed to the underlying
1270
+ HTTP request library.
1271
+
1272
+ :return:
1273
+ - An :class:`~scim2_models.Error` object in case of error.
1274
+ - The updated object as returned by the server in case of success if status code is 200.
1275
+ - :data:`None` in case of success if status code is 204.
1276
+
1277
+ :usage:
1278
+
1279
+ .. code-block:: python
1280
+ :caption: Modification of a `User` resource
1281
+
1282
+ from scim2_models import User, PatchOp, PatchOperation
1283
+
1284
+ operation = PatchOperation(
1285
+ op="replace", path="displayName", value="New Display Name"
1286
+ )
1287
+ patch_op = PatchOp[User](operations=[operation])
1288
+ response = await scim.modify(User, "my-user-id", patch_op)
1289
+ # 'response' may be a User, None, or an Error object
1290
+
1291
+ .. tip::
1292
+
1293
+ Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
1294
+ and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
1295
+ which value will excluded from the request payload, and which values are expected in
1296
+ the response payload.
1297
+ """
1298
+ raise NotImplementedError()
1299
+
1100
1300
  async def discover(
1101
1301
  self, schemas=True, resource_types=True, service_provider_config=True
1102
1302
  ):
@@ -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
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: scim2-client
3
- Version: 0.5.1
3
+ Version: 0.6.0
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
@@ -208,6 +208,7 @@ License: Apache License
208
208
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
209
209
  See the License for the specific language governing permissions and
210
210
  limitations under the License.
211
+ License-File: LICENSE.md
211
212
  Keywords: api,httpx,provisioning,rfc7643,rfc7644,scim,scim2
212
213
  Classifier: Development Status :: 3 - Alpha
213
214
  Classifier: Environment :: Web Environment
@@ -222,7 +223,7 @@ Classifier: Programming Language :: Python :: 3.12
222
223
  Classifier: Programming Language :: Python :: 3.13
223
224
  Classifier: Programming Language :: Python :: Implementation :: CPython
224
225
  Requires-Python: >=3.9
225
- Requires-Dist: scim2-models>=0.2.0
226
+ Requires-Dist: scim2-models>=0.4.1
226
227
  Provides-Extra: httpx
227
228
  Requires-Dist: httpx>=0.28.0; extra == 'httpx'
228
229
  Provides-Extra: werkzeug
@@ -0,0 +1,11 @@
1
+ scim2_client/__init__.py,sha256=l0pyBLiTpFA68ao98PqQLT_Xx0mw8BHumwrIHYCWa_M,845
2
+ scim2_client/client.py,sha256=Kglw0HfWHaQQ9hLiaDN92KuEn2KGHWxykm_sWNoUg1c,54867
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.0.dist-info/METADATA,sha256=vFPWc7J8NmtsibX_W-wZCvgRLMauHZX5lpCI4DxyCw4,16986
9
+ scim2_client-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
10
+ scim2_client-0.6.0.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ scim2_client-0.6.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.26.3
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,11 +0,0 @@
1
- scim2_client/__init__.py,sha256=l0pyBLiTpFA68ao98PqQLT_Xx0mw8BHumwrIHYCWa_M,845
2
- scim2_client/client.py,sha256=Ti7LNwCSa_98GK6_LkG-X0GFMf5HQ1KQ4gOubUc16to,46367
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.1.dist-info/METADATA,sha256=eJVaMV-MJtKvkH3V9LyPw8hpMy_6-1W_tKCOpLFtP3A,16961
9
- scim2_client-0.5.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
10
- scim2_client-0.5.1.dist-info/licenses/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
- scim2_client-0.5.1.dist-info/RECORD,,