Skip to content

Commit 8a1113a

Browse files
authored
Merge pull request #31 from python-scim/patch
Support for PATCH operations
2 parents d4b55b5 + f4b845a commit 8a1113a

File tree

10 files changed

+914
-60
lines changed

10 files changed

+914
-60
lines changed

doc/changelog.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
Changelog
22
=========
33

4+
[0.6.0] - Unreleased
5+
--------------------
6+
7+
Fixed
8+
^^^^^
9+
- Add support for PATCH operations with :meth:`~scim2_client.SCIMClient.modify`.
10+
411
[0.5.2] - 2025-07-17
512
--------------------
613

doc/tutorial.rst

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ The following actions are available:
8181
- :meth:`~scim2_client.BaseSyncSCIMClient.create`
8282
- :meth:`~scim2_client.BaseSyncSCIMClient.query`
8383
- :meth:`~scim2_client.BaseSyncSCIMClient.replace`
84+
- :meth:`~scim2_client.BaseSyncSCIMClient.modify`
8485
- :meth:`~scim2_client.BaseSyncSCIMClient.delete`
8586
- :meth:`~scim2_client.BaseSyncSCIMClient.search`
8687

@@ -97,9 +98,72 @@ Have a look at the :doc:`reference` to see usage examples and the exhaustive set
9798
9899
return f"User {user.id} have been created!"
99100
101+
PATCH modifications
102+
===================
103+
104+
The :meth:`~scim2_client.BaseSyncSCIMClient.modify` method allows you to perform partial updates on resources using PATCH operations as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
105+
106+
.. code-block:: python
107+
108+
from scim2_models import PatchOp, PatchOperation
109+
110+
# Create a patch operation to update the display name
111+
operation = PatchOperation(
112+
op=PatchOperation.Op.replace_,
113+
path="displayName",
114+
value="New Display Name"
115+
)
116+
patch_op = PatchOp[User](operations=[operation])
117+
118+
# Apply the patch
119+
response = scim.modify(User, user_id, patch_op)
120+
if response: # Server returned 200 with updated resource
121+
print(f"User updated: {response.display_name}")
122+
else: # Server returned 204 (no content)
123+
print("User updated successfully")
124+
125+
Multiple Operations
126+
~~~~~~~~~~~~~~~~~~~
127+
128+
You can include multiple operations in a single PATCH request:
129+
130+
.. code-block:: python
131+
132+
operations = [
133+
PatchOperation(
134+
op=PatchOperation.Op.replace_,
135+
path="displayName",
136+
value="Updated Name"
137+
),
138+
PatchOperation(
139+
op=PatchOperation.Op.replace_,
140+
path="active",
141+
value=False
142+
),
143+
PatchOperation(
144+
op=PatchOperation.Op.add,
145+
path="emails",
146+
value=[{"value": "[email protected]", "primary": True}]
147+
)
148+
]
149+
patch_op = PatchOp[User](operations=operations)
150+
response = scim.modify(User, user_id, patch_op)
151+
152+
Patch Operation Types
153+
~~~~~~~~~~~~~~~~~~~~~
154+
155+
SCIM supports three types of patch operations:
156+
157+
- :attr:`~scim2_models.PatchOperation.Op.add`: Add new attribute values
158+
- :attr:`~scim2_models.PatchOperation.Op.remove`: Remove attribute values
159+
- :attr:`~scim2_models.PatchOperation.Op.replace_`: Replace existing attribute values
160+
161+
Bulk operations
162+
===============
163+
100164
.. note::
101165

102-
PATCH modification and bulk operation request are not yet implement,
166+
Bulk operation requests are not yet implemented,
103167
but :doc:`any help is welcome! <contributing>`
104168

105169
Request and response validation

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ classifiers = [
2727

2828
requires-python = ">= 3.9"
2929
dependencies = [
30-
"scim2-models>=0.2.0",
30+
"scim2-models>=0.4.1",
3131
]
3232

3333
[project.optional-dependencies]
@@ -54,7 +54,7 @@ dev = [
5454
"pytest-asyncio>=0.24.0",
5555
"pytest-coverage>=0.0",
5656
"pytest-httpserver>=1.1.0",
57-
"scim2-server >= 0.1.2; python_version>='3.10'",
57+
"scim2-server >= 0.1.6; python_version>='3.10'",
5858
"tox-uv>=1.16.0",
5959
"werkzeug>=3.1.3",
6060
]

scim2_client/client.py

Lines changed: 209 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Collection
44
from dataclasses import dataclass
55
from typing import Optional
6+
from typing import TypeVar
67
from typing import Union
78

89
from pydantic import ValidationError
@@ -27,6 +28,8 @@
2728
from scim2_client.errors import UnexpectedContentType
2829
from scim2_client.errors import UnexpectedStatusCode
2930

31+
ResourceT = TypeVar("ResourceT", bound=Resource)
32+
3033
BASE_HEADERS = {
3134
"Accept": "application/scim+json",
3235
"Content-Type": "application/scim+json",
@@ -151,6 +154,26 @@ class SCIMClient:
151154
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
152155
"""
153156

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+
154177
def __init__(
155178
self,
156179
resource_models: Optional[Collection[type[Resource]]] = None,
@@ -299,11 +322,15 @@ def check_response(
299322
if not expected_types:
300323
return response_payload
301324

325+
# For no-content responses, return None directly
326+
if response_payload is None:
327+
return None
328+
302329
actual_type = Resource.get_by_payload(
303330
expected_types, response_payload, with_extensions=False
304331
)
305332

306-
if response_payload and not actual_type:
333+
if not actual_type:
307334
expected = ", ".join([type_.__name__ for type_ in expected_types])
308335
try:
309336
schema = ", ".join(response_payload["schemas"])
@@ -534,9 +561,76 @@ def prepare_replace_request(
534561

535562
return req
536563

564+
def prepare_patch_request(
565+
self,
566+
resource_model: type[ResourceT],
567+
id: str,
568+
patch_op: Union[PatchOp[ResourceT], dict],
569+
check_request_payload: Optional[bool] = None,
570+
expected_status_codes: Optional[list[int]] = None,
571+
raise_scim_errors: Optional[bool] = None,
572+
**kwargs,
573+
) -> RequestPayload:
574+
"""Prepare a PATCH request payload.
575+
576+
:param resource_model: The resource type to modify (e.g., User, Group).
577+
:param id: The resource ID.
578+
:param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
579+
(e.g., PatchOp[User] when resource_model is User), or a dict representation.
580+
:param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
581+
that will be passed as-is in the request. This value can be
582+
overwritten in methods.
583+
:param expected_status_codes: List of HTTP status codes expected for this request.
584+
:param raise_scim_errors: If :data:`True` and the server returned an
585+
:class:`~scim2_models.Error` object during a request, a
586+
:class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
587+
:param kwargs: Additional request parameters.
588+
:return: The prepared request payload.
589+
"""
590+
req = RequestPayload(
591+
expected_status_codes=expected_status_codes,
592+
request_kwargs=kwargs,
593+
)
594+
595+
if check_request_payload is None:
596+
check_request_payload = self.check_request_payload
597+
598+
self.check_resource_model(resource_model)
599+
600+
if not check_request_payload:
601+
req.payload = patch_op
602+
req.url = req.request_kwargs.pop(
603+
"url", f"{self.resource_endpoint(resource_model)}/{id}"
604+
)
605+
606+
else:
607+
if isinstance(patch_op, dict):
608+
req.payload = patch_op
609+
else:
610+
try:
611+
req.payload = patch_op.model_dump(
612+
scim_ctx=Context.RESOURCE_PATCH_REQUEST
613+
)
614+
except ValidationError as exc:
615+
scim_validation_exc = RequestPayloadValidationError(source=patch_op)
616+
if sys.version_info >= (3, 11): # pragma: no cover
617+
scim_validation_exc.add_note(str(exc))
618+
raise scim_validation_exc from exc
619+
620+
req.url = req.request_kwargs.pop(
621+
"url", f"{self.resource_endpoint(resource_model)}/{id}"
622+
)
623+
624+
req.expected_types = [resource_model]
625+
return req
626+
537627
def modify(
538-
self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
539-
) -> Optional[Union[AnyResource, dict]]:
628+
self,
629+
resource_model: type[ResourceT],
630+
id: str,
631+
patch_op: Union[PatchOp[ResourceT], dict],
632+
**kwargs,
633+
) -> Optional[Union[ResourceT, Error, dict]]:
540634
raise NotImplementedError()
541635

542636
def build_resource_models(
@@ -820,6 +914,62 @@ def replace(
820914
"""
821915
raise NotImplementedError()
822916

917+
def modify(
918+
self,
919+
resource_model: type[ResourceT],
920+
id: str,
921+
patch_op: Union[PatchOp[ResourceT], dict],
922+
check_request_payload: Optional[bool] = None,
923+
check_response_payload: Optional[bool] = None,
924+
expected_status_codes: Optional[
925+
list[int]
926+
] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
927+
raise_scim_errors: Optional[bool] = None,
928+
**kwargs,
929+
) -> Optional[Union[ResourceT, Error, dict]]:
930+
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
931+
932+
:param resource_model: The type of the resource to modify.
933+
:param id: The id of the resource to modify.
934+
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
935+
Must be parameterized with the same resource type as ``resource_model``
936+
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
937+
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
938+
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
939+
:param expected_status_codes: The list of expected status codes form the response.
940+
If :data:`None` any status code is accepted.
941+
:param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
942+
:param kwargs: Additional parameters passed to the underlying
943+
HTTP request library.
944+
945+
:return:
946+
- An :class:`~scim2_models.Error` object in case of error.
947+
- The updated object as returned by the server in case of success if status code is 200.
948+
- :data:`None` in case of success if status code is 204.
949+
950+
:usage:
951+
952+
.. code-block:: python
953+
:caption: Modification of a `User` resource
954+
955+
from scim2_models import User, PatchOp, PatchOperation
956+
957+
operation = PatchOperation(
958+
op="replace", path="displayName", value="New Display Name"
959+
)
960+
patch_op = PatchOp[User](operations=[operation])
961+
response = scim.modify(User, "my-user-id", patch_op)
962+
# 'response' may be a User, None, or an Error object
963+
964+
.. tip::
965+
966+
Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
967+
and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
968+
which value will excluded from the request payload, and which values are expected in
969+
the response payload.
970+
"""
971+
raise NotImplementedError()
972+
823973
def discover(self, schemas=True, resource_types=True, service_provider_config=True):
824974
"""Dynamically discover the server configuration objects.
825975
@@ -1097,6 +1247,62 @@ async def replace(
10971247
"""
10981248
raise NotImplementedError()
10991249

1250+
async def modify(
1251+
self,
1252+
resource_model: type[ResourceT],
1253+
id: str,
1254+
patch_op: Union[PatchOp[ResourceT], dict],
1255+
check_request_payload: Optional[bool] = None,
1256+
check_response_payload: Optional[bool] = None,
1257+
expected_status_codes: Optional[
1258+
list[int]
1259+
] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
1260+
raise_scim_errors: Optional[bool] = None,
1261+
**kwargs,
1262+
) -> Optional[Union[ResourceT, Error, dict]]:
1263+
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
1264+
1265+
:param resource_model: The type of the resource to modify.
1266+
:param id: The id of the resource to modify.
1267+
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
1268+
Must be parameterized with the same resource type as ``resource_model``
1269+
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
1270+
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
1271+
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
1272+
:param expected_status_codes: The list of expected status codes form the response.
1273+
If :data:`None` any status code is accepted.
1274+
:param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
1275+
:param kwargs: Additional parameters passed to the underlying
1276+
HTTP request library.
1277+
1278+
:return:
1279+
- An :class:`~scim2_models.Error` object in case of error.
1280+
- The updated object as returned by the server in case of success if status code is 200.
1281+
- :data:`None` in case of success if status code is 204.
1282+
1283+
:usage:
1284+
1285+
.. code-block:: python
1286+
:caption: Modification of a `User` resource
1287+
1288+
from scim2_models import User, PatchOp, PatchOperation
1289+
1290+
operation = PatchOperation(
1291+
op="replace", path="displayName", value="New Display Name"
1292+
)
1293+
patch_op = PatchOp[User](operations=[operation])
1294+
response = await scim.modify(User, "my-user-id", patch_op)
1295+
# 'response' may be a User, None, or an Error object
1296+
1297+
.. tip::
1298+
1299+
Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
1300+
and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
1301+
which value will excluded from the request payload, and which values are expected in
1302+
the response payload.
1303+
"""
1304+
raise NotImplementedError()
1305+
11001306
async def discover(
11011307
self, schemas=True, resource_types=True, service_provider_config=True
11021308
):

0 commit comments

Comments
 (0)