Skip to content
Open
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.7.6] - Next release
--------------------

Added
^^^^^
- Support for bulk operations with :meth:`~scim2_client.SCIMClient.bulk`.

[0.7.5] - 2026-04-02
--------------------

Expand Down
62 changes: 59 additions & 3 deletions doc/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -319,10 +319,66 @@ Modify
Bulk
~~~~

.. note::
:meth:`~scim2_client.BaseSyncSCIMClient.bulk` issues a ``POST`` on the ``/Bulk`` endpoint to execute multiple operations at once:

Bulk operation requests are not yet implemented,
but :doc:`any help is welcome! <contributing>`
.. tab-set::
:class: outline

.. tab-item:: Sync
:sync: sync

.. code-block:: python

from scim2_models import BulkRequest, BulkOperation, Group, GroupMember, User

request = BulkRequest(
operations=[
BulkOperation(
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation(
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = scim.bulk(request)

.. tab-item:: Async
:sync: async

.. code-block:: python

from scim2_models import BulkRequest, BulkOperation, Group, GroupMember, User

request = BulkRequest(
operations=[
BulkOperation(
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation(
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = await scim.bulk(request)

Error handling
==============
Expand Down
189 changes: 189 additions & 0 deletions scim2_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from pydantic import ValidationError
from scim2_models import AnyResource
from scim2_models import BulkRequest
from scim2_models import BulkResponse
from scim2_models import Context
from scim2_models import Error
from scim2_models import Extension
Expand Down Expand Up @@ -118,6 +120,25 @@ class SCIMClient:
:rfc:`RFC7644 §3.12 <7644#section-3.12>`.
"""

BULK_RESPONSE_STATUS_CODES: list[int] = [
200,
307,
308,
400,
401,
403,
404,
409,
413,
500,
501,
]
"""Bulk request HTTP codes.

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

DELETION_RESPONSE_STATUS_CODES: list[int] = [
204,
307,
Expand Down Expand Up @@ -513,6 +534,38 @@ def _prepare_search_request(
req.expected_types = [ListResponse[Union[self.resource_models]]] # noqa: UP007
return req

def _prepare_bulk_request(
self,
bulk_request: BulkRequest | None = None,
check_request_payload: bool | None = None,
expected_status_codes: list[int] | None = None,
**kwargs,
) -> RequestPayload:
req = RequestPayload(
expected_status_codes=expected_status_codes,
request_kwargs=kwargs,
)

if check_request_payload is None:
check_request_payload = self.check_request_payload

if not check_request_payload:
req.payload = bulk_request

else:
req.payload = (
bulk_request.model_dump(
scim_ctx=Context.RESOURCE_CREATION_REQUEST,
polymorphic_serialization=True,
)
if bulk_request
else None
)

req.url = req.request_kwargs.pop("url", "/Bulk")
req.expected_types = [BulkResponse] # noqa: UP007
return req

def _prepare_delete_request(
self,
resource_model: type[Resource],
Expand Down Expand Up @@ -856,6 +909,74 @@ def search(
"""
raise NotImplementedError()

def bulk(
self,
bulk_request: BulkRequest | None = None,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
expected_status_codes: list[int] | None = SCIMClient.BULK_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> BulkResponse | Error | dict:
"""Perform a POST bulk request to execute bulk operations, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`.

:param bulk_request: An object detailing the bulk request.
: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:
- A :class:`~scim2_models.Error` object in case of error.
- A :class:`~scim2_models.BulkResponse` object in case of success.

:usage:

.. code-block:: python
:caption: Simultaneously creating a `User` resource and a `Group` resource containing the user

from scim2_models import (
BulkRequest,
BulkOperation,
Group,
GroupMember,
User,
)

req = BulkRequest(
operations=[
BulkOperation(
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation(
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = scim.bulk(req)
# 'response' may be a BulkResponse or an Error object

.. tip::

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

def delete(
self,
resource_model: type,
Expand Down Expand Up @@ -1191,6 +1312,74 @@ async def search(
"""
raise NotImplementedError()

async def bulk(
self,
bulk_request: BulkRequest | None = None,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
expected_status_codes: list[int] | None = SCIMClient.BULK_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> BulkResponse | Error | dict:
"""Perform a POST bulk request to execute bulk operations, as defined in :rfc:`RFC7644 §3.7 <7644#section-3.7>`.

:param bulk_request: An object detailing the bulk request.
: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:
- A :class:`~scim2_models.Error` object in case of error.
- A :class:`~scim2_models.BulkResponse` object in case of success.

:usage:

.. code-block:: python
:caption: Simultaneously creating a `User` resource and a `Group` resource containing the user

from scim2_models import (
BulkRequest,
BulkOperation,
Group,
GroupMember,
User,
)

req = BulkRequest(
operations=[
BulkOperation(
method="POST",
path="/Users",
bulk_id="qwerty",
data=User(user_name="Alice"),
),
BulkOperation(
method="POST",
path="/Groups",
bulk_id="ytrewq",
data=Group(
display_name="Tour Guides",
members=[GroupMember(type="User", value="bulkId:qwerty")],
),
),
]
)
response = scim.bulk(req)
# 'response' may be a BulkResponse or an Error object

.. tip::

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

async def delete(
self,
resource_model: type,
Expand Down
68 changes: 68 additions & 0 deletions scim2_client/engines/httpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from httpx import RequestError
from httpx import Response
from scim2_models import AnyResource
from scim2_models import BulkRequest
from scim2_models import BulkResponse
from scim2_models import Context
from scim2_models import Error
from scim2_models import ListResponse
Expand Down Expand Up @@ -176,6 +178,38 @@ def search(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)

def bulk(
self,
bulk_request: BulkRequest | None = None,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
expected_status_codes: list[int]
| None = BaseSyncSCIMClient.BULK_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> BulkResponse | Error | dict:
req = self._prepare_bulk_request(
bulk_request=bulk_request,
check_request_payload=check_request_payload,
expected_status_codes=expected_status_codes,
**kwargs,
)

with handle_request_error(req.payload):
response = self.client.post(req.url, json=req.payload, **req.request_kwargs)

with handle_response_error(response):
return self.check_response(
payload=response.json() if response.text else None,
status_code=response.status_code,
headers=response.headers,
expected_status_codes=req.expected_status_codes,
expected_types=req.expected_types,
check_response_payload=check_response_payload,
raise_scim_errors=raise_scim_errors,
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
)

def delete(
self,
resource_model: type[Resource],
Expand Down Expand Up @@ -408,6 +442,40 @@ async def search(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)

async def bulk(
self,
bulk_request: BulkRequest | None = None,
check_request_payload: bool | None = None,
check_response_payload: bool | None = None,
expected_status_codes: list[int]
| None = BaseSyncSCIMClient.BULK_RESPONSE_STATUS_CODES,
raise_scim_errors: bool | None = None,
**kwargs,
) -> BulkResponse | Error | dict:
req = self._prepare_bulk_request(
bulk_request=bulk_request,
check_request_payload=check_request_payload,
expected_status_codes=expected_status_codes,
**kwargs,
)

with handle_request_error(req.payload):
response = await self.client.post(
req.url, json=req.payload, **req.request_kwargs
)

with handle_response_error(response):
return self.check_response(
payload=response.json() if response.text else None,
status_code=response.status_code,
headers=response.headers,
expected_status_codes=req.expected_status_codes,
expected_types=req.expected_types,
check_response_payload=check_response_payload,
raise_scim_errors=raise_scim_errors,
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
)

async def delete(
self,
resource_model: type[Resource],
Expand Down
Loading
Loading