diff --git a/doc/changelog.rst b/doc/changelog.rst index a9ae601..1984f63 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -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 -------------------- diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 89683c1..397b868 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -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` @@ -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! ` Request and response validation diff --git a/pyproject.toml b/pyproject.toml index 595466a..d4a42c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ requires-python = ">= 3.9" dependencies = [ - "scim2-models>=0.2.0", + "scim2-models>=0.4.1", ] [project.optional-dependencies] @@ -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", ] diff --git a/scim2_client/client.py b/scim2_client/client.py index ab020a6..881d575 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -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 @@ -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", @@ -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, @@ -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"]) @@ -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( @@ -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. @@ -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 ): diff --git a/scim2_client/engines/httpx.py b/scim2_client/engines/httpx.py index 6ee6cbd..b7384ee 100644 --- a/scim2_client/engines/httpx.py +++ b/scim2_client/engines/httpx.py @@ -2,6 +2,7 @@ import sys from contextlib import contextmanager from typing import Optional +from typing import TypeVar from typing import Union from httpx import Client @@ -11,6 +12,7 @@ from scim2_models import Context from scim2_models import Error from scim2_models import ListResponse +from scim2_models import PatchOp from scim2_models import Resource from scim2_models import SearchRequest @@ -20,6 +22,8 @@ from scim2_client.errors import SCIMClientError from scim2_client.errors import UnexpectedContentFormat +ResourceT = TypeVar("ResourceT", bound=Resource) + @contextmanager def handle_request_error(payload=None): @@ -240,6 +244,46 @@ def replace( scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, ) + 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] + ] = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, + raise_scim_errors: Optional[bool] = None, + **kwargs, + ) -> Optional[Union[ResourceT, Error, dict]]: + req = self.prepare_patch_request( + resource_model=resource_model, + id=id, + patch_op=patch_op, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.patch( + 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_PATCH_RESPONSE, + ) + class AsyncSCIMClient(BaseAsyncSCIMClient): """Perform SCIM requests over the network and validate responses. @@ -441,3 +485,43 @@ async def replace( raise_scim_errors=raise_scim_errors, scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, ) + + 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] + ] = BaseAsyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, + raise_scim_errors: Optional[bool] = None, + **kwargs, + ) -> Optional[Union[ResourceT, Error, dict]]: + req = self.prepare_patch_request( + resource_model=resource_model, + id=id, + patch_op=patch_op, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.patch( + 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_PATCH_RESPONSE, + ) diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py index 7ce5df1..b942f65 100644 --- a/scim2_client/engines/werkzeug.py +++ b/scim2_client/engines/werkzeug.py @@ -1,6 +1,7 @@ import json from contextlib import contextmanager from typing import Optional +from typing import TypeVar from typing import Union from urllib.parse import urlencode @@ -8,6 +9,7 @@ from scim2_models import Context from scim2_models import Error from scim2_models import ListResponse +from scim2_models import PatchOp from scim2_models import Resource from scim2_models import SearchRequest from werkzeug.test import Client @@ -16,6 +18,8 @@ from scim2_client.errors import SCIMClientError from scim2_client.errors import UnexpectedContentFormat +ResourceT = TypeVar("ResourceT", bound=Resource) + @contextmanager def handle_response_error(response): @@ -273,3 +277,43 @@ def replace( raise_scim_errors=raise_scim_errors, scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, ) + + 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] + ] = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, + raise_scim_errors: Optional[bool] = None, + **kwargs, + ) -> Optional[Union[ResourceT, Error, dict]]: + req = self.prepare_patch_request( + resource_model=resource_model, + id=id, + patch_op=patch_op, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + environ = {**self.environ, **req.request_kwargs} + response = self.client.patch( + self.make_url(req.url), json=req.payload, **environ + ) + + 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_PATCH_RESPONSE, + ) diff --git a/tests/engines/test_httpx.py b/tests/engines/test_httpx.py index 1237ba5..8d001a7 100644 --- a/tests/engines/test_httpx.py +++ b/tests/engines/test_httpx.py @@ -5,6 +5,8 @@ import pytest from httpx import AsyncClient from httpx import Client +from scim2_models import PatchOp +from scim2_models import PatchOperation from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig @@ -79,6 +81,17 @@ def test_sync_engine(server): assert response_user.user_name == "foo" assert response_user.display_name == "baz" + # Test patch operation followed by query + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="patched name" + ) + patch_op = PatchOp[User](operations=[operation]) + scim_client.modify(User, response_user.id, patch_op) + + # Verify patch result with query + queried_user = scim_client.query(User, response_user.id) + assert queried_user.display_name == "patched name" + scim_client.delete(User, response_user.id) with pytest.raises(SCIMResponseErrorObject): scim_client.query(User, response_user.id) @@ -100,28 +113,43 @@ async def test_async_engine(server): assert isinstance(scim_client.service_provider_config, ServiceProviderConfig) User = scim_client.get_resource_model("User") - request_user = User(user_name="foo", display_name="bar") + request_user = User(user_name="async_foo", display_name="async_bar") response_user = await scim_client.create(request_user) - assert response_user.user_name == "foo" - assert response_user.display_name == "bar" + assert response_user.user_name == "async_foo" + assert response_user.display_name == "async_bar" response_user = await scim_client.query(User, response_user.id) - assert response_user.user_name == "foo" - assert response_user.display_name == "bar" + assert response_user.user_name == "async_foo" + assert response_user.display_name == "async_bar" req = SearchRequest() response_users = await scim_client.search(req) - assert response_users.resources[0].user_name == "foo" - assert response_users.resources[0].display_name == "bar" + # Find our user among all users + our_user = next(u for u in response_users.resources if u.user_name == "async_foo") + assert our_user.user_name == "async_foo" + assert our_user.display_name == "async_bar" - request_user = User(id=response_user.id, user_name="foo", display_name="baz") + request_user = User( + id=response_user.id, user_name="async_foo", display_name="async_baz" + ) response_user = await scim_client.replace(request_user) - assert response_user.user_name == "foo" - assert response_user.display_name == "baz" + assert response_user.user_name == "async_foo" + assert response_user.display_name == "async_baz" response_user = await scim_client.query(User, response_user.id) - assert response_user.user_name == "foo" - assert response_user.display_name == "baz" + assert response_user.user_name == "async_foo" + assert response_user.display_name == "async_baz" + + # Test patch operation followed by query + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="async patched name" + ) + patch_op = PatchOp[User](operations=[operation]) + await scim_client.modify(User, response_user.id, patch_op) + + # Verify patch result with query + queried_user = await scim_client.query(User, response_user.id) + assert queried_user.display_name == "async patched name" await scim_client.delete(User, response_user.id) with pytest.raises(SCIMResponseErrorObject): diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py index 2b39820..e194bcf 100644 --- a/tests/engines/test_werkzeug.py +++ b/tests/engines/test_werkzeug.py @@ -1,4 +1,6 @@ import pytest +from scim2_models import PatchOp +from scim2_models import PatchOperation from scim2_models import SearchRequest from scim2_models import User from werkzeug.test import Client @@ -59,6 +61,17 @@ def test_werkzeug_engine(scim_client): assert response_user.user_name == "foo" assert response_user.display_name == "baz" + # Test patch operation followed by query + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="werkzeug patched" + ) + patch_op = PatchOp[User](operations=[operation]) + scim_client.modify(User, response_user.id, patch_op) + + # Verify patch result with query + queried_user = scim_client.query(User, response_user.id) + assert queried_user.display_name == "werkzeug patched" + scim_client.delete(User, response_user.id) with pytest.raises(SCIMResponseErrorObject): scim_client.query(User, response_user.id) diff --git a/tests/test_modify.py b/tests/test_modify.py new file mode 100644 index 0000000..6dc8e08 --- /dev/null +++ b/tests/test_modify.py @@ -0,0 +1,408 @@ +import pytest +from scim2_models import Error +from scim2_models import Group +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import ResourceType +from scim2_models import User + +from scim2_client import RequestNetworkError +from scim2_client import RequestPayloadValidationError +from scim2_client import SCIMRequestError + + +def test_modify_user_200(httpserver, sync_client): + """Nominal case for a User modification with 200 response (resource returned).""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "displayName": "Updated Display Name", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W\\/"3694e05e9dff590"', + "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + }, + status=200, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Updated Display Name" + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op + ) + + assert isinstance(response, User) + assert response.id == "2819c223-7f76-453a-919d-413861904646" + assert response.display_name == "Updated Display Name" + + +def test_modify_user_204(httpserver, sync_client): + """Nominal case for a User modification with 204 response (no content).""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_data( + "", + status=204, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="active", value=False + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op + ) + + assert response is None + + +def test_modify_user_multiple_operations(httpserver, sync_client): + """Test User modification with multiple patch operations.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "displayName": "Betty Jane", + "active": False, + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W\\/"3694e05e9dff591"', + "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + }, + status=200, + content_type="application/scim+json", + ) + + operations = [ + PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Betty Jane" + ), + PatchOperation(op=PatchOperation.Op.replace_, path="active", value=False), + ] + patch_op = PatchOp[User](operations=operations) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op + ) + + assert isinstance(response, User) + assert response.display_name == "Betty Jane" + assert response.active is False + + +def test_modify_user_add_operation(httpserver, sync_client): + """Test User modification with add operation.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "emails": [{"value": "bjensen@example.com", "primary": True}], + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W\\/"3694e05e9dff591"', + "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + }, + status=200, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.add, + path="emails", + value=[{"value": "bjensen@example.com", "primary": True}], + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op + ) + + assert isinstance(response, User) + assert len(response.emails) == 1 + assert response.emails[0].value == "bjensen@example.com" + + +def test_modify_user_remove_operation(httpserver, sync_client): + """Test User modification with remove operation.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "meta": { + "resourceType": "User", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W\\/"3694e05e9dff591"', + "location": "https://example.com/v2/Users/2819c223-7f76-453a-919d-413861904646", + }, + }, + status=200, + content_type="application/scim+json", + ) + + operation = PatchOperation(op=PatchOperation.Op.remove, path="displayName") + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op + ) + + assert isinstance(response, User) + assert response.display_name is None + + +def test_modify_group(httpserver, sync_client): + """Test Group modification.""" + httpserver.expect_request( + "/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "e9e30dba-f08f-4109-8486-d5c6a331660a", + "displayName": "Updated Tour Guides", + "meta": { + "resourceType": "Group", + "created": "2010-01-23T04:56:22Z", + "lastModified": "2011-05-13T04:42:34Z", + "version": 'W\\/"3694e05e9dff592"', + "location": "https://example.com/v2/Groups/e9e30dba-f08f-4109-8486-d5c6a331660a", + }, + }, + status=200, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Updated Tour Guides" + ) + patch_op = PatchOp[Group](operations=[operation]) + + response = sync_client.modify( + Group, "e9e30dba-f08f-4109-8486-d5c6a331660a", patch_op + ) + + assert isinstance(response, Group) + assert response.display_name == "Updated Tour Guides" + + +def test_dont_check_response_payload(httpserver, sync_client): + """Test the check_response_payload attribute.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json({"foo": "bar"}, status=200) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Test" + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, + "2819c223-7f76-453a-919d-413861904646", + patch_op, + check_response_payload=False, + ) + assert response == {"foo": "bar"} + + +def test_dont_check_request_payload(httpserver, sync_client): + """Test the check_request_payload attribute.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + "displayName": "Updated Name", + }, + status=200, + content_type="application/scim+json", + ) + + patch_op_dict = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + {"op": "replace", "path": "displayName", "value": "Updated Name"} + ], + } + + response = sync_client.modify( + User, + "2819c223-7f76-453a-919d-413861904646", + patch_op_dict, + check_request_payload=False, + ) + assert response.id == "2819c223-7f76-453a-919d-413861904646" + assert response.display_name == "Updated Name" + + +@pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 412, 500, 501]) +def test_errors(httpserver, code, sync_client): + """Test error cases defined in RFC7644.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_json( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": str(code), + "detail": f"{code} error", + }, + status=code, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Test" + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify( + User, "2819c223-7f76-453a-919d-413861904646", patch_op, raise_scim_errors=False + ) + + assert response == Error( + schemas=["urn:ietf:params:scim:api:messages:2.0:Error"], + status=code, + detail=f"{code} error", + ) + + +def test_invalid_resource_model(httpserver, sync_client): + """Test that resource_models passed to the method must be part of SCIMClient.resource_models.""" + sync_client.resource_models = (User,) + sync_client.resource_types = [ResourceType.from_resource(User)] + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Test" + ) + patch_op = PatchOp[Group](operations=[operation]) + + with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + sync_client.modify(Group, "some-id", patch_op) + + +def test_request_validation_error(httpserver, sync_client): + """Test that incorrect PatchOp creation raises a validation error.""" + # Test with a PatchOp that has invalid data - this should fail during model_dump in prepare_patch_request + with pytest.raises( + (RequestPayloadValidationError, ValueError, TypeError), + match=r"(?i)(validation|invalid|error)", + ): + # Create a PatchOp with invalid enum value by bypassing normal validation + # This will fail when the client tries to serialize it + from unittest.mock import Mock + + invalid_patch_op = Mock() + invalid_patch_op.model_dump.side_effect = ValueError("Invalid operation type") + sync_client.modify(User, "some-id", invalid_patch_op) + + +def test_request_network_error(httpserver, sync_client): + """Test that httpx exceptions are transformed in RequestNetworkError.""" + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="displayName", value="Test" + ) + patch_op = PatchOp[User](operations=[operation]) + + with pytest.raises( + RequestNetworkError, match="Network error happened during request" + ): + sync_client.modify(User, "some-id", patch_op, url="http://invalid.test") + + +def test_custom_url(httpserver, sync_client): + """Test modify with custom URL.""" + httpserver.expect_request( + "/custom/path/users/123", method="PATCH" + ).respond_with_data( + "", + status=204, + content_type="application/scim+json", + ) + + operation = PatchOperation( + op=PatchOperation.Op.replace_, path="active", value=False + ) + patch_op = PatchOp[User](operations=[operation]) + + response = sync_client.modify(User, "123", patch_op, url="/custom/path/users/123") + + assert response is None + + +def test_modify_with_dict_patch_op(httpserver, sync_client): + """Test modify with dict patch_op.""" + httpserver.expect_request( + "/Users/2819c223-7f76-453a-919d-413861904646", method="PATCH" + ).respond_with_data( + "", + status=204, + content_type="application/scim+json", + ) + + # Use a dict instead of PatchOp object with check_request_payload=True + patch_op_dict = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "displayName", "value": "Dict Patch"}], + } + + response = sync_client.modify( + User, + "2819c223-7f76-453a-919d-413861904646", + patch_op_dict, + check_request_payload=True, + ) + + assert response is None + + +def test_modify_validation_error(httpserver, sync_client): + """Test that PatchOp validation errors are handled properly.""" + from unittest.mock import Mock + + from pydantic import ValidationError + from scim2_models import PatchOp + + # Create a mock PatchOp that raises ValidationError on model_dump + invalid_patch_op = Mock() + + # Create a proper ValidationError using pytest.raises + with pytest.raises(ValidationError) as exc_info: + PatchOp[User](operations="invalid") + + invalid_patch_op.model_dump.side_effect = exc_info.value + + with pytest.raises( + RequestPayloadValidationError, + match="Server request payload validation error", + ): + sync_client.modify(User, "some-id", invalid_patch_op) diff --git a/uv.lock b/uv.lock index b3c4264..48164c7 100644 --- a/uv.lock +++ b/uv.lock @@ -289,11 +289,11 @@ toml = [ [[package]] name = "distlib" -version = "0.3.9" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] @@ -332,7 +332,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1120,7 +1120,7 @@ doc = [ [package.metadata] requires-dist = [ { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.0" }, - { name = "scim2-models", specifier = ">=0.2.0" }, + { name = "scim2-models", specifier = ">=0.4.1" }, { name = "werkzeug", marker = "extra == 'werkzeug'", specifier = ">=3.1.3" }, ] provides-extras = ["httpx", "werkzeug"] @@ -1134,7 +1134,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-coverage", specifier = ">=0.0" }, { name = "pytest-httpserver", specifier = ">=1.1.0" }, - { name = "scim2-server", marker = "python_full_version >= '3.10'", specifier = ">=0.1.2" }, + { name = "scim2-server", marker = "python_full_version >= '3.10'", specifier = ">=0.1.6" }, { name = "tox-uv", specifier = ">=1.16.0" }, { name = "werkzeug", specifier = ">=3.1.3" }, ] @@ -1161,28 +1161,28 @@ wheels = [ [[package]] name = "scim2-models" -version = "0.3.6" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/f1/b0409e6df664184e915925b0f661dfc19a646a5c9197001a20b1b0bd4d22/scim2_models-0.3.6.tar.gz", hash = "sha256:db458417443202bbf8cd864748e22212381d431affb019c38d0285fa823fea84", size = 140799, upload-time = "2025-07-02T08:50:17.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/52/1fc9628e0062a81a431eaa91a4108a0a9ea06665780bc68312d3a839684b/scim2_models-0.4.1.tar.gz", hash = "sha256:49150f3baee8b5f66cadd8c64324ee979bc8ad8454d9615a2f47ef02d55dff57", size = 158395, upload-time = "2025-07-23T14:27:49.166Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/34/3430d22c59cbef2a3e80f3d1f5a81a67a3731c8158a474e21fa10917a381/scim2_models-0.3.6-py3-none-any.whl", hash = "sha256:08837bbb09e06294def71c43b6f22f80d88b072b24e3cb892554c66655fac7e2", size = 41037, upload-time = "2025-07-02T08:50:16.372Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/9bd11edb7cf94402a9fa89ef8664c41b8594f66030e8837803e57b99ee1b/scim2_models-0.4.1-py3-none-any.whl", hash = "sha256:e6fb2ac949aa005a115b7af16c66b9472afbbbc29d3d3a5402907ad5bf9ed67e", size = 51677, upload-time = "2025-07-23T14:27:47.873Z" }, ] [[package]] name = "scim2-server" -version = "0.1.5" +version = "0.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "scim2-filter-parser", marker = "python_full_version >= '3.10'" }, { name = "scim2-models", marker = "python_full_version >= '3.10'" }, { name = "werkzeug", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/46/493a8f34f09c298252157c6e5fa710e36d2f5e70e7efa3c02c7929cc287e/scim2_server-0.1.5.tar.gz", hash = "sha256:5422552e7620dfe2b3eff0d75a7657905a9756ccec776866ddc2d8db6278351c", size = 85333, upload-time = "2025-03-28T12:14:39.321Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/30/18d25d1d2af0780f48d4fe4d73d8815f4572f4e0308045cce425e05053b6/scim2_server-0.1.6.tar.gz", hash = "sha256:01b694112f759c7ecc9fe7cdecb16bf382a49225771c5d934d93fb2d345752bb", size = 89096, upload-time = "2025-07-23T15:40:44.311Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/f9/936f26382e5205f7b05d2a1d409f232c378d56c6d4cef7e3adb1e6816222/scim2_server-0.1.5-py3-none-any.whl", hash = "sha256:0b14d19d9ae80244ababfb41b565d3ebe9c60f191fe7af10cbfe4df67ac14fe0", size = 33140, upload-time = "2025-03-28T12:14:37.645Z" }, + { url = "https://files.pythonhosted.org/packages/15/cf/af8b242b1affb5f72ec1709f3c1381c1cf041f5045d7c231c19a758568e0/scim2_server-0.1.6-py3-none-any.whl", hash = "sha256:850649b621c8e255179643774fa4375211a41ab2a9b25f1e5ae0ab892c5e35d6", size = 33159, upload-time = "2025-07-23T15:40:42.8Z" }, ] [[package]] @@ -1441,7 +1441,7 @@ wheels = [ [[package]] name = "tox" -version = "4.27.0" +version = "4.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, @@ -1456,14 +1456,14 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/b7/19c01717747076f63c54d871ada081cd711a7c9a7572f2225675c3858b94/tox-4.27.0.tar.gz", hash = "sha256:b97d5ecc0c0d5755bcc5348387fef793e1bfa68eb33746412f4c60881d7f5f57", size = 198351, upload-time = "2025-06-17T15:17:50.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/a3/6fdc3f17aad7f2971739c6efc537a692d619c54acb224d90e62733346a60/tox-4.28.1.tar.gz", hash = "sha256:227ce1fdfea7763107aed3a8ac87d74b1bd1240ad7dd9c37fc2cb2b318006520", size = 199598, upload-time = "2025-07-23T06:16:39.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3a/30889167f41ecaffb957ec4409e1cbc1d5d558a5bbbdfb734a5b9911930f/tox-4.27.0-py3-none-any.whl", hash = "sha256:2b8a7fb986b82aa2c830c0615082a490d134e0626dbc9189986da46a313c4f20", size = 173441, upload-time = "2025-06-17T15:17:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/b9/60/0ba9aa30a0a3d0faaebeadd9c847812a929416a4acda50d92c7079a9ec00/tox-4.28.1-py3-none-any.whl", hash = "sha256:d5c84de6efc5d7e8acadb09528943e87ee501a35e064cf852082bc600485c13d", size = 173973, upload-time = "2025-07-23T06:16:37.54Z" }, ] [[package]] name = "tox-uv" -version = "1.26.1" +version = "1.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, @@ -1471,9 +1471,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.10'" }, { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/00/98e564731fc361cc2f1e39c58d2feb0b4c9f9a7cb06f0c769cdeb9a98004/tox_uv-1.26.1.tar.gz", hash = "sha256:241cc530b4a80436c4487977c8303d9aace398c6561d5e7d8845606fa7d482ab", size = 21849, upload-time = "2025-06-23T20:17:54.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/31/2c02a8b4d85d3538d6e9a7aa55dfaf3ea372b2007496b9235047e18c0953/tox_uv-1.26.2.tar.gz", hash = "sha256:5270d5d49e26c1303d902b90d6143a593b43ae148ccc5107251b79bf5bd4fefd", size = 21895, upload-time = "2025-07-21T17:03:39.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0b/e47c1bb2bc9e20b22a6913ea2162b7bb5729d38924fa2c1d4eaf95d3b36f/tox_uv-1.26.1-py3-none-any.whl", hash = "sha256:edc25b254e5cdbb13fc5d23d6d05b511dee562ab72b0e99da4a874a78018c38e", size = 16661, upload-time = "2025-06-23T20:17:52.492Z" }, + { url = "https://files.pythonhosted.org/packages/73/c9/354b2a28112ce9619616f09a3e8363dae01f9a4c5a2716fa92bcfcf6ccc5/tox_uv-1.26.2-py3-none-any.whl", hash = "sha256:f95c8635b6e046534faf4de88f46c46ac0d644f2dbe0104fc6adac637e0d44b6", size = 16666, upload-time = "2025-07-21T17:03:38.037Z" }, ] [[package]] @@ -1508,41 +1508,41 @@ wheels = [ [[package]] name = "uv" -version = "0.7.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/5f/d4356a6e9643fac913dc37b84fe1fb9e1baa34ce8dff17a214db2f4198cb/uv-0.7.21.tar.gz", hash = "sha256:9da06b797370c32f9ac9766257602258960d686e3847e102d9c293a77f8449e7", size = 3382889, upload-time = "2025-07-14T18:35:35.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/18/9abc4463eda4151c8f4e2be82d2c6ea1b6787a79eded84a9c35a7359ea69/uv-0.7.21-py3-none-linux_armv6l.whl", hash = "sha256:dbcee21780bc9df9e328d6ec2f02e236cdf1483e570cb627945e2f1389875c85", size = 17772814, upload-time = "2025-07-14T18:34:36.344Z" }, - { url = "https://files.pythonhosted.org/packages/0e/f5/151c1272e4cf902a04345b3ad5ed3cfe91c8780bcbf0bfe25277b4effd84/uv-0.7.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9dd65dea88bd6ad3728aab0b176a83da794f5a7a103a9ee3f0085fb57f030d2f", size = 17904679, upload-time = "2025-07-14T18:34:41.044Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/866bebfb01ae04619b335c4981fa11914543f3dfa73bc2c1d7008cf285a3/uv-0.7.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a62e72cec3596e5dc78ec5077f5d0616c100fcf9758fa5d41e8b3b00335c439e", size = 16591601, upload-time = "2025-07-14T18:34:44.571Z" }, - { url = "https://files.pythonhosted.org/packages/24/4d/19913eddd03e1787be2deeb97134210c0d8b92dfb34f26409d51994e5ce1/uv-0.7.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:7d86149d80b69da65987d1b3f03f5285d13bcf033424d2fdad646efd36f77257", size = 17107772, upload-time = "2025-07-14T18:34:48.255Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/91e38332d4db2fe36f782e227238af0b689785cff57b169c92aacd249e21/uv-0.7.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306e2b349dd41b177c2157471d99fa6deffae3098b3747ca7a90cbf0a69f44dc", size = 17504089, upload-time = "2025-07-14T18:34:51.868Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ea/5b0d2dac76bdcf3f4055a46b7cb6b4271d6db96f00fb5c8eda063189ceb7/uv-0.7.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef23d2c2e5fa32dc91edfaea22d094a7cecf2a1bb58f5e8cf916e0a4049b9200", size = 18217722, upload-time = "2025-07-14T18:34:55.008Z" }, - { url = "https://files.pythonhosted.org/packages/38/16/39a8fdb7ec4a65800925895903bdbc2fefda0624a10f3e9f6690e74dd071/uv-0.7.21-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:48ebc0585f073c83086c1f8a21aeeb8a9f68c361a4989d1fbf24bcae83782a5d", size = 19487934, upload-time = "2025-07-14T18:34:58.367Z" }, - { url = "https://files.pythonhosted.org/packages/b5/23/eaed96f06d4fecfebcee6ea4d656e5b06fb61cab58ccc4098526bbca5e8b/uv-0.7.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91e3e4decfba5ac6e6c11bd9879350c1a140ec952169d4a137c5d1fceea6fb9d", size = 19228362, upload-time = "2025-07-14T18:35:01.763Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/86b3a9efc6202a4266bbed44b4e3145f758f37b3e52f39e1de115ae1c04f/uv-0.7.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:45e9d63ccdd79f0d24a5e99ff6cd449439a5b1a9b84c5fa1d4a6c9e9b4419c91", size = 18722277, upload-time = "2025-07-14T18:35:05.13Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8d/6613d8c04d16f4b953a5b973219e76a61f80a92a5c17b6b250e1770e4341/uv-0.7.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfea37104d3d06134a43fc62038d9c073f5d8ecce2f524bdb60608c43484494c", size = 18559914, upload-time = "2025-07-14T18:35:08.718Z" }, - { url = "https://files.pythonhosted.org/packages/e8/23/5ebfe6f6d0227b35dddeb5f1da621e8fe3eeb49a8bed151f45920b2f3e7e/uv-0.7.21-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8d6a1364fe39aeed30fbf3427f6f43a27d8a113e0a9cb42386851cd365e956e4", size = 17356304, upload-time = "2025-07-14T18:35:12.177Z" }, - { url = "https://files.pythonhosted.org/packages/8e/38/536bbcd74fa5960ae395b345f7655bbd932064d457524a5e8847331ed9d8/uv-0.7.21-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:5012308754f6377f7a2522b50d6ba8bda003f15feb755adbc2ab2353c0b96523", size = 17414580, upload-time = "2025-07-14T18:35:15.382Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2f/c8043de9ad200d5ccab0ab8001f460f1cb7f1f7e262320345b2bf1244bc5/uv-0.7.21-py3-none-musllinux_1_1_i686.whl", hash = "sha256:51fd21c2a10ea7a4dc535a83bd2e21650236151671946cf98ed643561648c87b", size = 17780951, upload-time = "2025-07-14T18:35:19.597Z" }, - { url = "https://files.pythonhosted.org/packages/c4/50/0681914033a438e1beb5e89a11637f97e6feb1ea4a6f2b87d5a8f1b57cac/uv-0.7.21-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:93e32169381afaf9a2c32ff10b28c8f8e86ec1e0273fb2beb186fdd214ecee32", size = 18710644, upload-time = "2025-07-14T18:35:23.062Z" }, - { url = "https://files.pythonhosted.org/packages/03/5d/7b034993b1460ef50426610eeb66126c57782e90480f013e2c5d3d8ed892/uv-0.7.21-py3-none-win32.whl", hash = "sha256:0797c1f51ee8c5db742a69b7d8c2948e8474ddbeeefcea792ab9f70a34890fca", size = 17660978, upload-time = "2025-07-14T18:35:26.177Z" }, - { url = "https://files.pythonhosted.org/packages/3d/9d/c6bb652111ff0b4cb34c4141267eaa91d8d2d9774617d2a5f424bb8ffa74/uv-0.7.21-py3-none-win_amd64.whl", hash = "sha256:6f3a5c02531deeb28fda27a6aa0184d9aaf2cd5d5875ea4e3424206545a042dd", size = 19442643, upload-time = "2025-07-14T18:35:29.966Z" }, - { url = "https://files.pythonhosted.org/packages/2b/82/a2710aa914164bf782500e233a198adc22cec48ef716e40d5681866003b3/uv-0.7.21-py3-none-win_arm64.whl", hash = "sha256:2a69a4c4d85b3edf91aeb6aa9f6bcedf423df0e4dfccae6b33410843c8b2d359", size = 18001982, upload-time = "2025-07-14T18:35:33.294Z" }, +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/44/b53ae1f6b08724ddf3ec7125ddb6a381bd64ccdd696e8d87a1db3502aa10/uv-0.8.2.tar.gz", hash = "sha256:1a2c6d332a4c38f7489f08829aea19cd1e276df7f2c6e51ae64ed92f8574cd68", size = 3412893, upload-time = "2025-07-22T20:36:33.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/40/03c5cd3c9fa0fb53dcce1a39a6a9da6d81f29057cf9c4b9bb850dc58e2fb/uv-0.8.2-py3-none-linux_armv6l.whl", hash = "sha256:a89c9a471fbb436063e79afa919b2fb27462900f0f3781f776d8fd0b874acd56", size = 17875572, upload-time = "2025-07-22T20:35:33.672Z" }, + { url = "https://files.pythonhosted.org/packages/22/13/1d97c67fe666112c4327d6eec8bf39c244931c08848d4c95be0a80017f19/uv-0.8.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a3b147064f69455b4558263228b3fdb053c3d550f25d41b049c4d34f1f77d74c", size = 17948871, upload-time = "2025-07-22T20:35:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/72/ec/0dd7b14f92de906afa3adde0f31e05150d081f1aadce9eb77689e3adc4ca/uv-0.8.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ebface4a113c493d953554460429731d44ede2427ba97e606955daadcc6e7ddc", size = 16660631, upload-time = "2025-07-22T20:35:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bf/4896cde38c29cbca42d1d0f73d80e15e20826968817150323a34c8b23436/uv-0.8.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6fb07dd58a2cb79640109c0604aeec57d1062fad89114c0fda2f9dbe3de3c0bb", size = 17208209, upload-time = "2025-07-22T20:35:45.355Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/f03b95ee6bb8c7bc4d6596664235992d1931d6e6b1b018acda6aeab69ea2/uv-0.8.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6af1b0f0f5f1416e94a6b098a595f360303a0024b21cf563d4e6139e6dd72640", size = 17570752, upload-time = "2025-07-22T20:35:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/00/44/3c905c0bce2113a664432c50b7a605eea9f271d126333b2a6c9ec5105ef3/uv-0.8.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bddf1ceceaddbc3f2cf2ebdad3213482d6dab3d1b452ddfecd35468e3b2f0e6", size = 18207389, upload-time = "2025-07-22T20:35:52.415Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/8a384dc22db96f54642889fa609192f79cd58447755d90b1163a9ba5d812/uv-0.8.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:bffe304fec46d6c264c3b34d58b3358764b7cc17af13bd421e1cd1300b706f93", size = 19633291, upload-time = "2025-07-22T20:35:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/46/c6/fd7855f0aba4a07a5b7a08b95cd6b9d264b534a3bc5095a4acb55aca1d46/uv-0.8.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d53b79f8304e98ee082336fc4c204a7892d15f78799ec2d59ceb09b0b82e45d", size = 19381757, upload-time = "2025-07-22T20:36:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d7/4752b2f6a9aaad324483f433e659b2fe36996015f348fadf172e5056b94e/uv-0.8.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f618b18b19fef09b087f2e637b2138f570a3c41beb3de4bbbb905a8b994a22a", size = 18671374, upload-time = "2025-07-22T20:36:03.672Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/f979657cc773f17d0c1ec4a1d17dc1b0673ab484d00833aa5514982faf63/uv-0.8.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20f2b1eb4bf8b8197683e057ea9a8f0eb63b682ad20bb232b58529abac73a5ea", size = 18666053, upload-time = "2025-07-22T20:36:07.059Z" }, + { url = "https://files.pythonhosted.org/packages/94/8d/6ff7188911b671e3eedf87ea2ce4f1e39bbbaf27caa74eef92cd9824051d/uv-0.8.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8d3ae329606ba586d317e9a06dca213619afb407bcc584cf6cff2a9b84cf25a2", size = 17462711, upload-time = "2025-07-22T20:36:10.29Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/4ce00ce186f2c02ca8708a5532102ffa9e0f87a0346521a4db83a04a56b4/uv-0.8.2-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:040ceacad98b85f9ca5ab8c8220270e6a60b2136c4889b334dbfcd13812f895f", size = 17534469, upload-time = "2025-07-22T20:36:13.439Z" }, + { url = "https://files.pythonhosted.org/packages/69/ad/bf37f8bea961278ae5719f23a0998dcae17c431a77d3fa9e0d3a9256a2b2/uv-0.8.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f15bcfd21ca66ec93b77e6ff612798dc75d54260e2ec52f07fe897e91f07367", size = 17787669, upload-time = "2025-07-22T20:36:17.053Z" }, + { url = "https://files.pythonhosted.org/packages/03/49/3b7e1c926bcdf1325aba9647cf1831c55ae84d7d2319a74f2d9ad88535fb/uv-0.8.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9dea45737afec83739189834648b07595ce07f4c201021cc5545ee1759dfe25d", size = 18759496, upload-time = "2025-07-22T20:36:20.371Z" }, + { url = "https://files.pythonhosted.org/packages/d4/74/6b4e52593d1f469250e89ee85964011e9b84b2fc25e15e9353800f36d5ab/uv-0.8.2-py3-none-win32.whl", hash = "sha256:eb37db94c9295bfec77ff65fbc56b9962665d3d5bff0989dcb440c650351ee15", size = 17753486, upload-time = "2025-07-22T20:36:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/1d9510c5aeda8c14e6c088f1720e2dd818dc72ed37a0ee40d3d3137aabcf/uv-0.8.2-py3-none-win_amd64.whl", hash = "sha256:0fcaab1172c6fae036a9f16460a71812f7a427b3d3779f99457c2d537a3fc250", size = 19503370, upload-time = "2025-07-22T20:36:27.505Z" }, + { url = "https://files.pythonhosted.org/packages/00/38/8907e8fc94e3c040759180e81d30414734cbee6e575dae7ce9dc9cb1e0fc/uv-0.8.2-py3-none-win_arm64.whl", hash = "sha256:af35c0fe23907fc0518832243b561f623a48a058a75ab552204f87960793321b", size = 18145751, upload-time = "2025-07-22T20:36:30.826Z" }, ] [[package]] name = "virtualenv" -version = "20.31.2" +version = "20.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" }, ] [[package]]