Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
Changelog
=========

[0.6.0] - Unreleased
--------------------

Fixed
^^^^^
- Add support for PATCH operations with :meth:`~scim2_client.SCIMClient.modify`.

[0.5.2] - 2025-07-17
--------------------

Expand Down
66 changes: 65 additions & 1 deletion doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ The following actions are available:
- :meth:`~scim2_client.BaseSyncSCIMClient.create`
- :meth:`~scim2_client.BaseSyncSCIMClient.query`
- :meth:`~scim2_client.BaseSyncSCIMClient.replace`
- :meth:`~scim2_client.BaseSyncSCIMClient.modify`
- :meth:`~scim2_client.BaseSyncSCIMClient.delete`
- :meth:`~scim2_client.BaseSyncSCIMClient.search`

Expand All @@ -97,9 +98,72 @@ Have a look at the :doc:`reference` to see usage examples and the exhaustive set

return f"User {user.id} have been created!"

PATCH modifications
===================

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>`.

.. code-block:: python

from scim2_models import PatchOp, PatchOperation

# Create a patch operation to update the display name
operation = PatchOperation(
op=PatchOperation.Op.replace_,
path="displayName",
value="New Display Name"
)
patch_op = PatchOp[User](operations=[operation])

# Apply the patch
response = scim.modify(User, user_id, patch_op)
if response: # Server returned 200 with updated resource
print(f"User updated: {response.display_name}")
else: # Server returned 204 (no content)
print("User updated successfully")

Multiple Operations
~~~~~~~~~~~~~~~~~~~

You can include multiple operations in a single PATCH request:

.. code-block:: python

operations = [
PatchOperation(
op=PatchOperation.Op.replace_,
path="displayName",
value="Updated Name"
),
PatchOperation(
op=PatchOperation.Op.replace_,
path="active",
value=False
),
PatchOperation(
op=PatchOperation.Op.add,
path="emails",
value=[{"value": "new@example.com", "primary": True}]
)
]
patch_op = PatchOp[User](operations=operations)
response = scim.modify(User, user_id, patch_op)

Patch Operation Types
~~~~~~~~~~~~~~~~~~~~~

SCIM supports three types of patch operations:

- :attr:`~scim2_models.PatchOperation.Op.add`: Add new attribute values
- :attr:`~scim2_models.PatchOperation.Op.remove`: Remove attribute values
- :attr:`~scim2_models.PatchOperation.Op.replace_`: Replace existing attribute values

Bulk operations
===============

.. note::

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

Request and response validation
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ classifiers = [

requires-python = ">= 3.9"
dependencies = [
"scim2-models>=0.2.0",
"scim2-models>=0.4.1",
]

[project.optional-dependencies]
Expand All @@ -54,7 +54,7 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-coverage>=0.0",
"pytest-httpserver>=1.1.0",
"scim2-server >= 0.1.2; python_version>='3.10'",
"scim2-server >= 0.1.6; python_version>='3.10'",
"tox-uv>=1.16.0",
"werkzeug>=3.1.3",
]
Expand Down
212 changes: 209 additions & 3 deletions scim2_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Collection
from dataclasses import dataclass
from typing import Optional
from typing import TypeVar
from typing import Union

from pydantic import ValidationError
Expand All @@ -27,6 +28,8 @@
from scim2_client.errors import UnexpectedContentType
from scim2_client.errors import UnexpectedStatusCode

ResourceT = TypeVar("ResourceT", bound=Resource)

BASE_HEADERS = {
"Accept": "application/scim+json",
"Content-Type": "application/scim+json",
Expand Down Expand Up @@ -151,6 +154,26 @@ class SCIMClient:
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
"""

PATCH_RESPONSE_STATUS_CODES: list[int] = [
200,
204,
307,
308,
400,
401,
403,
404,
409,
412,
500,
501,
]
"""Resource patching HTTP codes.

As defined at :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` and
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
"""

def __init__(
self,
resource_models: Optional[Collection[type[Resource]]] = None,
Expand Down Expand Up @@ -299,11 +322,15 @@ def check_response(
if not expected_types:
return response_payload

# For no-content responses, return None directly
if response_payload is None:
return None

actual_type = Resource.get_by_payload(
expected_types, response_payload, with_extensions=False
)

if response_payload and not actual_type:
if not actual_type:
expected = ", ".join([type_.__name__ for type_ in expected_types])
try:
schema = ", ".join(response_payload["schemas"])
Expand Down Expand Up @@ -534,9 +561,76 @@ def prepare_replace_request(

return req

def prepare_patch_request(
self,
resource_model: type[ResourceT],
id: str,
patch_op: Union[PatchOp[ResourceT], dict],
check_request_payload: Optional[bool] = None,
expected_status_codes: Optional[list[int]] = None,
raise_scim_errors: Optional[bool] = None,
**kwargs,
) -> RequestPayload:
"""Prepare a PATCH request payload.

:param resource_model: The resource type to modify (e.g., User, Group).
:param id: The resource ID.
:param patch_op: A PatchOp instance parameterized with the same resource type as resource_model
(e.g., PatchOp[User] when resource_model is User), or a dict representation.
:param check_request_payload: If :data:`False`, :code:`patch_op` is expected to be a dict
that will be passed as-is in the request. This value can be
overwritten in methods.
:param expected_status_codes: List of HTTP status codes expected for this request.
:param raise_scim_errors: If :data:`True` and the server returned an
:class:`~scim2_models.Error` object during a request, a
:class:`~scim2_client.SCIMResponseErrorObject` exception will be raised.
:param kwargs: Additional request parameters.
:return: The prepared request payload.
"""
req = RequestPayload(
expected_status_codes=expected_status_codes,
request_kwargs=kwargs,
)

if check_request_payload is None:
check_request_payload = self.check_request_payload

self.check_resource_model(resource_model)

if not check_request_payload:
req.payload = patch_op
req.url = req.request_kwargs.pop(
"url", f"{self.resource_endpoint(resource_model)}/{id}"
)

else:
if isinstance(patch_op, dict):
req.payload = patch_op
else:
try:
req.payload = patch_op.model_dump(
scim_ctx=Context.RESOURCE_PATCH_REQUEST
)
except ValidationError as exc:
scim_validation_exc = RequestPayloadValidationError(source=patch_op)
if sys.version_info >= (3, 11): # pragma: no cover
scim_validation_exc.add_note(str(exc))
raise scim_validation_exc from exc

req.url = req.request_kwargs.pop(
"url", f"{self.resource_endpoint(resource_model)}/{id}"
)

req.expected_types = [resource_model]
return req

def modify(
self, resource: Union[AnyResource, dict], op: PatchOp, **kwargs
) -> Optional[Union[AnyResource, dict]]:
self,
resource_model: type[ResourceT],
id: str,
patch_op: Union[PatchOp[ResourceT], dict],
**kwargs,
) -> Optional[Union[ResourceT, Error, dict]]:
raise NotImplementedError()

def build_resource_models(
Expand Down Expand Up @@ -820,6 +914,62 @@ def replace(
"""
raise NotImplementedError()

def modify(
self,
resource_model: type[ResourceT],
id: str,
patch_op: Union[PatchOp[ResourceT], dict],
check_request_payload: Optional[bool] = None,
check_response_payload: Optional[bool] = None,
expected_status_codes: Optional[
list[int]
] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
raise_scim_errors: Optional[bool] = None,
**kwargs,
) -> Optional[Union[ResourceT, Error, dict]]:
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.

:param resource_model: The type of the resource to modify.
:param id: The id of the resource to modify.
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
Must be parameterized with the same resource type as ``resource_model``
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
If :data:`None` any status code is accepted.
:param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
:param kwargs: Additional parameters passed to the underlying
HTTP request library.

:return:
- An :class:`~scim2_models.Error` object in case of error.
- The updated object as returned by the server in case of success if status code is 200.
- :data:`None` in case of success if status code is 204.

:usage:

.. code-block:: python
:caption: Modification of a `User` resource

from scim2_models import User, PatchOp, PatchOperation

operation = PatchOperation(
op="replace", path="displayName", value="New Display Name"
)
patch_op = PatchOp[User](operations=[operation])
response = scim.modify(User, "my-user-id", patch_op)
# 'response' may be a User, None, or an Error object

.. tip::

Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
which value will excluded from the request payload, and which values are expected in
the response payload.
"""
raise NotImplementedError()

def discover(self, schemas=True, resource_types=True, service_provider_config=True):
"""Dynamically discover the server configuration objects.

Expand Down Expand Up @@ -1097,6 +1247,62 @@ async def replace(
"""
raise NotImplementedError()

async def modify(
self,
resource_model: type[ResourceT],
id: str,
patch_op: Union[PatchOp[ResourceT], dict],
check_request_payload: Optional[bool] = None,
check_response_payload: Optional[bool] = None,
expected_status_codes: Optional[
list[int]
] = SCIMClient.PATCH_RESPONSE_STATUS_CODES,
raise_scim_errors: Optional[bool] = None,
**kwargs,
) -> Optional[Union[ResourceT, Error, dict]]:
"""Perform a PATCH request to modify a resource, as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.

:param resource_model: The type of the resource to modify.
:param id: The id of the resource to modify.
:param patch_op: The :class:`~scim2_models.PatchOp` object describing the modifications.
Must be parameterized with the same resource type as ``resource_model``
(e.g., :code:`PatchOp[User]` when ``resource_model`` is :code:`User`).
:param check_request_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_request_payload`.
:param check_response_payload: If set, overwrites :paramref:`scim2_client.SCIMClient.check_response_payload`.
:param expected_status_codes: The list of expected status codes form the response.
If :data:`None` any status code is accepted.
:param raise_scim_errors: If set, overwrites :paramref:`scim2_client.SCIMClient.raise_scim_errors`.
:param kwargs: Additional parameters passed to the underlying
HTTP request library.

:return:
- An :class:`~scim2_models.Error` object in case of error.
- The updated object as returned by the server in case of success if status code is 200.
- :data:`None` in case of success if status code is 204.

:usage:

.. code-block:: python
:caption: Modification of a `User` resource

from scim2_models import User, PatchOp, PatchOperation

operation = PatchOperation(
op="replace", path="displayName", value="New Display Name"
)
patch_op = PatchOp[User](operations=[operation])
response = await scim.modify(User, "my-user-id", patch_op)
# 'response' may be a User, None, or an Error object

.. tip::

Check the :attr:`~scim2_models.Context.RESOURCE_PATCH_REQUEST`
and :attr:`~scim2_models.Context.RESOURCE_PATCH_RESPONSE` contexts to understand
which value will excluded from the request payload, and which values are expected in
the response payload.
"""
raise NotImplementedError()

async def discover(
self, schemas=True, resource_types=True, service_provider_config=True
):
Expand Down
Loading
Loading