diff --git a/README.md b/README.md index d854a3a..1948d04 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ Here is an example of usage: ```python import datetime from httpx2 import Client -from scim2_client import SCIMResponseErrorObject from scim2_client.engines.httpx2 import SyncSCIMClient +from scim2_models import SCIMException client = Client( base_url="https://auth.example/scim/v2", headers={"Authorization": "Bearer foobar"} @@ -70,7 +70,7 @@ assert user.meta.last_modified == datetime.datetime( user = User(user_name="bjensen@example.com") try: scim.create(user) -except SCIMResponseErrorObject as exc: +except SCIMException as exc: error = exc.to_error() assert ( error.detail diff --git a/doc/changelog.rst b/doc/changelog.rst index 34d4025..fbc83fd 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -14,6 +14,9 @@ Added Changed ^^^^^^^ - scim2-models 0.8 is not supported yet, and 0.7.0 is now the minimum supported version. +- **Breaking:** invalid requests and server :class:`~scim2_models.Error` objects now raise + :class:`~scim2_models.SCIMException` subclasses from scim2-models instead of scim2-client + custom exceptions. :issue:`39` Deprecated ^^^^^^^^^^ @@ -22,6 +25,16 @@ Deprecated Will be removed in 0.9. - Passing a :code:`httpx.Client` or a :code:`httpx.AsyncClient` to the request engines, in favor of their httpx2 counterparts. Will be removed in 0.9. +- The exceptions with a ``*Error`` suffix, in favor of their ``*Exception`` counterparts. + The old names still point at the renamed classes, so ``except`` blocks written against + them keep working. Will be removed in 0.9. + +Removed +^^^^^^^ +- **Breaking:** ``SCIMRequestError``, ``RequestPayloadValidationError`` and + ``SCIMResponseErrorObject``, which have no counterpart among the scim2-models exceptions. + Code catching them must catch :class:`~scim2_models.SCIMException` instead, which is also + what invalid request payloads and server errors now raise. Fixed ^^^^^ @@ -50,7 +63,7 @@ Changed Changed ^^^^^^^ -- :class:`~scim2_client.SCIMResponseErrorObject` now exposes a :meth:`~scim2_client.SCIMResponseErrorObject.to_error` method +- ``SCIMResponseErrorObject`` now exposes a ``to_error()`` method returning the :class:`~scim2_models.Error` object from the server. :issue:`37` [0.7.2] - 2026-02-03 @@ -60,14 +73,14 @@ Fixed ^^^^^ - Skip ``Content-Type`` header validation for 204 responses. :issue:`34` -[0.7.1] - 2025-01-25 +[0.7.1] - 2026-01-25 -------------------- Fixed ^^^^^ - ``schemas`` is no longer included in GET query parameters per RFC 7644 ยง3.4.2. -[0.7.0] - 2025-01-25 +[0.7.0] - 2026-01-25 -------------------- Added @@ -168,7 +181,7 @@ Added Added ^^^^^ - :class:`~scim2_client.engines.werkzeug.TestSCIMClient` raise a - :class:`~scim2_client.UnexpectedContentFormat` exception when response is not JSON. + ``UnexpectedContentFormat`` exception when response is not JSON. [0.3.2] - 2024-11-29 -------------------- @@ -224,7 +237,7 @@ Added Fixed ^^^^^ -- :class:`~scim2_client.RequestPayloadValidationError` error message. +- ``RequestPayloadValidationError`` error message. - Don't crash when servers don't return content type headers. :pr:`22,24` [0.2.0] - 2024-09-01 @@ -278,7 +291,7 @@ Fixed Added ^^^^^ -- :class:`~scim2_client.SCIMResponseErrorObject` implementation. +- ``SCIMResponseErrorObject`` implementation. [0.1.5] - 2024-06-05 -------------------- @@ -293,9 +306,9 @@ Added - :class:`~scim2_models.ServiceProviderConfig`, :class:`~scim2_models.ResourceType` and :class:`~scim2_models.Schema` are added to the default resource types list. - Any custom URL can be used with all the :class:`~scim2_client.SCIMClient` methods. -- :class:`~scim2_client.ResponsePayloadValidationError` implementation. -- :class:`~scim2_client.RequestPayloadValidationError` implementation. -- :class:`~scim2_client.RequestNetworkError` implementation. +- ``ResponsePayloadValidationError`` implementation. +- ``RequestPayloadValidationError`` implementation. +- ``RequestNetworkError`` implementation. Fixed ^^^^^ diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 1746312..e6f2733 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -334,8 +334,9 @@ Bulk Error handling ============== -By default, if the server returns an error, a :class:`~scim2_client.SCIMResponseErrorObject` exception is raised. -The :meth:`~scim2_client.SCIMResponseErrorObject.to_error` method gives access to the :class:`~scim2_models.Error` object: +By default, if the request payload is invalid or if the server returns an error, +a :class:`~scim2_models.SCIMException` exception is raised. +The :meth:`~scim2_models.SCIMException.to_error` method gives access to the :class:`~scim2_models.Error` object: .. tab-set:: :class: outline @@ -345,11 +346,11 @@ The :meth:`~scim2_client.SCIMResponseErrorObject.to_error` method gives access t .. code-block:: python - from scim2_client import SCIMResponseErrorObject + from scim2_models import SCIMException try: response = scim.create(request) - except SCIMResponseErrorObject as exc: + except SCIMException as exc: error = exc.to_error() print(f"SCIM error [{error.status}] {error.scim_type}: {error.detail}") @@ -358,14 +359,18 @@ The :meth:`~scim2_client.SCIMResponseErrorObject.to_error` method gives access t .. code-block:: python - from scim2_client import SCIMResponseErrorObject + from scim2_models import SCIMException try: response = await scim.create(request) - except SCIMResponseErrorObject as exc: + except SCIMException as exc: error = exc.to_error() print(f"SCIM error [{error.status}] {error.scim_type}: {error.detail}") +Exceptions raised while validating the request payload happen before anything is +sent. They keep the original :class:`~pydantic.ValidationError`, which lists every +invalid attribute, in :attr:`~BaseException.__cause__`. + Request and response validation =============================== @@ -374,16 +379,16 @@ However sometimes you want to accept invalid inputs and outputs. To achieve this, all the methods provide the following parameters, all are :data:`True` by default: - :paramref:`~scim2_client.SCIMClient.check_request_payload`: - If :data:`True` (the default) a :class:`~pydantic.ValidationError` will be raised if the input does not respect the SCIM standard. + If :data:`True` (the default) a :class:`~scim2_models.SCIMException` will be raised if the input does not respect the SCIM standard. If :data:`False`, input is expected to be a :data:`dict` that will be passed as-is in the request. - :paramref:`~scim2_client.SCIMClient.check_response_payload`: - If :data:`True` (the default) a :class:`~pydantic.ValidationError` will be raised if the server response does not respect the SCIM standard. + If :data:`True` (the default) a :class:`~scim2_client.ResponsePayloadValidationException` will be raised if the server response does not respect the SCIM standard. If :data:`False` the server response is returned as-is. - :code:`expected_status_codes`: The list of expected status codes in the response. If :data:`None` any status code is accepted. - If an unexpected status code is returned, a :class:`~scim2_client.errors.UnexpectedStatusCode` exception is raised. -- :paramref:`~scim2_client.SCIMClient.raise_scim_errors`: If :data:`True` (the default) and the server returned an :class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject` exception will be raised. - The :meth:`~scim2_client.SCIMResponseErrorObject.to_error` method gives access to the :class:`~scim2_models.Error` object. + If an unexpected status code is returned, a :class:`~scim2_client.errors.UnexpectedStatusCodeException` exception is raised. +- :paramref:`~scim2_client.SCIMClient.raise_scim_errors`: If :data:`True` (the default) and the server returned an :class:`~scim2_models.Error` object, a :class:`~scim2_models.SCIMException` exception will be raised. + The :meth:`~scim2_models.SCIMException.to_error` method gives access to the :class:`~scim2_models.Error` object. If :data:`False` the error object is returned directly. diff --git a/scim2_client/__init__.py b/scim2_client/__init__.py index c1a0cb1..c7646b1 100644 --- a/scim2_client/__init__.py +++ b/scim2_client/__init__.py @@ -1,27 +1,29 @@ +from . import errors from .client import BaseSyncSCIMClient from .client import SCIMClient -from .errors import RequestNetworkError -from .errors import RequestPayloadValidationError -from .errors import ResponsePayloadValidationError -from .errors import SCIMClientError -from .errors import SCIMRequestError -from .errors import SCIMResponseError -from .errors import SCIMResponseErrorObject -from .errors import UnexpectedContentFormat -from .errors import UnexpectedContentType -from .errors import UnexpectedStatusCode +from .errors import RequestNetworkException +from .errors import ResponsePayloadValidationException +from .errors import SCIMClientException +from .errors import SCIMResponseException +from .errors import UnexpectedContentFormatException +from .errors import UnexpectedContentTypeException +from .errors import UnexpectedStatusCodeException __all__ = [ "SCIMClient", "BaseSyncSCIMClient", - "SCIMClientError", - "SCIMRequestError", - "SCIMResponseError", - "SCIMResponseErrorObject", - "UnexpectedContentFormat", - "UnexpectedContentType", - "UnexpectedStatusCode", - "RequestPayloadValidationError", - "RequestNetworkError", - "ResponsePayloadValidationError", + "SCIMClientException", + "SCIMResponseException", + "RequestNetworkException", + "UnexpectedStatusCodeException", + "UnexpectedContentTypeException", + "UnexpectedContentFormatException", + "ResponsePayloadValidationException", ] + + +def __getattr__(name: str) -> type[SCIMClientException]: + if name not in errors._DEPRECATED_ALIASES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + return errors.deprecated_alias(name) diff --git a/scim2_client/client.py b/scim2_client/client.py index ecb4857..58ab232 100644 --- a/scim2_client/client.py +++ b/scim2_client/client.py @@ -11,6 +11,7 @@ from scim2_models import Context from scim2_models import Error from scim2_models import Extension +from scim2_models import InvalidValueException from scim2_models import ListResponse from scim2_models import PatchOp from scim2_models import Resource @@ -21,14 +22,12 @@ from scim2_models import ServiceProviderConfig from scim2_models import get_model_by_payload -from scim2_client.errors import RequestPayloadValidationError -from scim2_client.errors import ResponsePayloadValidationError -from scim2_client.errors import SCIMClientError -from scim2_client.errors import SCIMRequestError -from scim2_client.errors import SCIMResponseError -from scim2_client.errors import SCIMResponseErrorObject -from scim2_client.errors import UnexpectedContentType -from scim2_client.errors import UnexpectedStatusCode +from scim2_client.errors import ResponsePayloadValidationException +from scim2_client.errors import SCIMResponseException +from scim2_client.errors import UnexpectedContentTypeException +from scim2_client.errors import UnexpectedStatusCodeException +from scim2_client.errors import request_validation_exception +from scim2_client.errors import server_error_exception ResourceT = TypeVar("ResourceT", bound=Resource) @@ -68,7 +67,7 @@ class SCIMClient: :param check_response_content_type: Whether to validate that the response content types are valid. :param check_response_status_codes: Whether to validate that the response status codes are valid. :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` + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. .. note:: @@ -204,9 +203,7 @@ def get_resource_model(self, name: str) -> type[Resource] | None: return resource_model return None - def _check_resource_model( - self, resource_model: type[Resource], payload=None - ) -> None: + def _check_resource_model(self, resource_model: type[Resource]) -> None: schema_to_check = resource_model.__schema__ for element in self.resource_models: schema = element.__schema__ @@ -214,8 +211,8 @@ def _check_resource_model( return if resource_model not in CONFIG_RESOURCES: - raise SCIMRequestError( - f"Unknown resource type: '{resource_model}'", source=payload + raise InvalidValueException( + detail=f"Unknown resource type: '{resource_model}'" ) def resource_endpoint(self, resource_model: type[Resource] | None) -> str: @@ -239,7 +236,9 @@ def resource_endpoint(self, resource_model: type[Resource] | None) -> str: if schema == resource_type.schema_: return resource_type.endpoint - raise SCIMRequestError(f"No ResourceType is matching the schema: {schema}") + raise InvalidValueException( + detail=f"No ResourceType is matching the schema: {schema}" + ) def register_naive_resource_types(self): """Register a *naive* :class:`~scim2_models.ResourceType` for each :paramref:`resource_model `. @@ -262,7 +261,7 @@ def _check_status_codes( and expected_status_codes and status_code not in expected_status_codes ): - raise UnexpectedStatusCode(status_code) + raise UnexpectedStatusCodeException(status_code) def _check_content_types(self, headers: dict): # Interoperability considerations: The "application/scim+json" media @@ -277,7 +276,7 @@ def _check_content_types(self, headers: dict): self.check_response_content_type and actual_content_type not in expected_response_content_types ): - raise UnexpectedContentType(content_type=actual_content_type) + raise UnexpectedContentTypeException(content_type=actual_content_type) def check_response( self, @@ -315,7 +314,7 @@ def check_response( if response_payload and response_payload.get("schemas") == [Error.__schema__]: error = Error.model_validate(response_payload) if raise_scim_errors: - raise SCIMResponseErrorObject(error) + raise server_error_exception(error, scim_ctx=scim_ctx) return error self._check_status_codes(status_code, expected_status_codes) @@ -341,12 +340,12 @@ def check_response( f"Expected type {expected} but got undefined object with no schema" ) - raise SCIMResponseError(message) + raise SCIMResponseException(message) try: return actual_type.model_validate(response_payload, scim_ctx=scim_ctx) except ValidationError as exc: - scim_exc = ResponsePayloadValidationError() + scim_exc = ResponsePayloadValidationException() if sys.version_info >= (3, 11): # pragma: no cover scim_exc.add_note(str(exc)) raise scim_exc from exc @@ -376,19 +375,18 @@ def _prepare_create_request( else: resource_model = get_model_by_payload(self.resource_models, resource) if not resource_model: - raise SCIMRequestError( - "Cannot guess resource type from the payload" + raise InvalidValueException( + detail="Cannot guess resource type from the payload" ) try: resource = resource_model.model_validate(resource) except ValidationError as exc: - scim_validation_exc = RequestPayloadValidationError(source=resource) - if sys.version_info >= (3, 11): # pragma: no cover - scim_validation_exc.add_note(str(exc)) - raise scim_validation_exc from exc + raise request_validation_exception( + exc, Context.RESOURCE_CREATION_REQUEST + ) from exc - self._check_resource_model(resource_model, resource) + self._check_resource_model(resource_model) req.expected_types = [resource.__class__] req.url = req.request_kwargs.pop( "url", self.resource_endpoint(resource_model) @@ -472,7 +470,9 @@ def _prepare_query_request( elif resource_model == ServiceProviderConfig: req.expected_types = [resource_model] if id: - raise SCIMClientError("ServiceProviderConfig cannot have an id") + raise InvalidValueException( + detail="ServiceProviderConfig cannot have an id" + ) elif id: req.expected_types = [resource_model] @@ -557,23 +557,21 @@ def _prepare_replace_request( else: resource_model = get_model_by_payload(self.resource_models, resource) if not resource_model: - raise SCIMRequestError( - "Cannot guess resource type from the payload", - source=resource, + raise InvalidValueException( + detail="Cannot guess resource type from the payload" ) try: resource = resource_model.model_validate(resource) except ValidationError as exc: - scim_validation_exc = RequestPayloadValidationError(source=resource) - if sys.version_info >= (3, 11): # pragma: no cover - scim_validation_exc.add_note(str(exc)) - raise scim_validation_exc from exc + raise request_validation_exception( + exc, Context.RESOURCE_REPLACEMENT_REQUEST + ) from exc - self._check_resource_model(resource_model, resource) + self._check_resource_model(resource_model) if not resource.id: - raise SCIMRequestError("Resource must have an id", source=resource) + raise InvalidValueException(detail="Resource must have an id") req.expected_types = [resource.__class__] req.payload = resource.model_dump( @@ -606,7 +604,7 @@ def _prepare_patch_request( :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. + :class:`~scim2_models.SCIMException` exception will be raised. :param kwargs: Additional request parameters. :return: The prepared request payload. """ @@ -635,10 +633,9 @@ def _prepare_patch_request( 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 + raise request_validation_exception( + exc, Context.RESOURCE_PATCH_REQUEST + ) from exc req.url = req.request_kwargs.pop( "url", f"{self.resource_endpoint(resource_model)}/{id}" diff --git a/scim2_client/engines/httpx2.py b/scim2_client/engines/httpx2.py index 8fc4bef..3b26d0d 100644 --- a/scim2_client/engines/httpx2.py +++ b/scim2_client/engines/httpx2.py @@ -28,13 +28,14 @@ from scim2_models import PatchOp from scim2_models import Resource from scim2_models import ResponseParameters +from scim2_models import SCIMException from scim2_models import SearchRequest from scim2_client.client import BaseAsyncSCIMClient from scim2_client.client import BaseSyncSCIMClient -from scim2_client.errors import RequestNetworkError -from scim2_client.errors import SCIMClientError -from scim2_client.errors import UnexpectedContentFormat +from scim2_client.errors import RequestNetworkException +from scim2_client.errors import SCIMClientException +from scim2_client.errors import UnexpectedContentFormatException ResourceT = TypeVar("ResourceT", bound=Resource) @@ -68,7 +69,7 @@ def handle_request_error(payload=None): yield except _request_error_classes() as exc: - scim_network_exc = RequestNetworkError(source=payload) + scim_network_exc = RequestNetworkException(source=payload) if sys.version_info >= (3, 11): # pragma: no cover scim_network_exc.add_note(str(exc)) raise scim_network_exc from exc @@ -80,9 +81,9 @@ def handle_response_error(response: Response): yield except json.decoder.JSONDecodeError as exc: - raise UnexpectedContentFormat(source=response) from exc + raise UnexpectedContentFormatException(source=response) from exc - except SCIMClientError as exc: + except (SCIMClientException, SCIMException) as exc: exc.source = response raise exc @@ -99,7 +100,7 @@ class SyncSCIMClient(BaseSyncSCIMClient): :param check_response_payload: Whether to validate that the response payloads are valid. If set, the raw payload will be returned. This value can be overwritten in methods. :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` + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. """ @@ -327,7 +328,7 @@ class AsyncSCIMClient(BaseAsyncSCIMClient): :param check_response_payload: Whether to validate that the response payloads are valid. If set, the raw payload will be returned. This value can be overwritten in methods. :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` + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. """ diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py index b2ad24d..c729ac9 100644 --- a/scim2_client/engines/werkzeug.py +++ b/scim2_client/engines/werkzeug.py @@ -10,12 +10,13 @@ from scim2_models import PatchOp from scim2_models import Resource from scim2_models import ResponseParameters +from scim2_models import SCIMException from scim2_models import SearchRequest from werkzeug.test import Client from scim2_client.client import BaseSyncSCIMClient -from scim2_client.errors import SCIMClientError -from scim2_client.errors import UnexpectedContentFormat +from scim2_client.errors import SCIMClientException +from scim2_client.errors import UnexpectedContentFormatException ResourceT = TypeVar("ResourceT", bound=Resource) @@ -26,9 +27,9 @@ def handle_response_error(response): yield except json.decoder.JSONDecodeError as exc: - raise UnexpectedContentFormat(source=response) from exc + raise UnexpectedContentFormatException(source=response) from exc - except SCIMClientError as exc: + except (SCIMClientException, SCIMException) as exc: exc.source = response raise exc @@ -52,7 +53,7 @@ class TestSCIMClient(BaseSyncSCIMClient): :param check_response_payload: Whether to validate that the response payloads are valid. If set, the raw payload will be returned. This value can be overwritten in methods. :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` + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_models.SCIMException` exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. .. code-block:: python diff --git a/scim2_client/errors.py b/scim2_client/errors.py index a779d8a..c6465dd 100644 --- a/scim2_client/errors.py +++ b/scim2_client/errors.py @@ -1,11 +1,14 @@ -from typing import TYPE_CHECKING +import sys +import warnings from typing import Any -if TYPE_CHECKING: - from scim2_models import Error +from pydantic import ValidationError +from scim2_models import Context +from scim2_models import Error +from scim2_models import SCIMException -class SCIMClientError(Exception): +class SCIMClientException(Exception): """Base exception for scim2-client. :param message: The exception reason. @@ -24,15 +27,12 @@ def __str__(self) -> str: return self.message or "UNKNOWN" -class SCIMRequestError(SCIMClientError): - """Base exception for errors happening during request payload building.""" +class RequestNetworkException(SCIMClientException): + """Exception raised when a network error happened during request. - -class RequestNetworkError(SCIMRequestError): - """Error raised when a network error happened during request. - - This error is raised when a :class:`httpx2.RequestError` has been caught while performing a request. - The original :class:`~httpx2.RequestError` is available with :attr:`~BaseException.__cause__`. + This exception is raised when a :class:`httpx2.RequestError` has been caught + while performing a request. The original :class:`~httpx2.RequestError` is + available with :attr:`~BaseException.__cause__`. """ def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -40,60 +40,12 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(message, *args, **kwargs) -class RequestPayloadValidationError(SCIMRequestError): - """Error raised when an invalid request payload has been passed to SCIMClient. - - This error is raised when a :class:`pydantic.ValidationError` has been caught - while validating the client request payload. - The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`. - - .. code-block:: python - - try: - scim.create( - { - "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], - "active": "not-a-bool", - } - ) - except RequestPayloadValidationError as exc: - print("Original validation error cause", exc.__cause__) - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - message = kwargs.pop("message", "Server request payload validation error") - super().__init__(message, *args, **kwargs) - - -class SCIMResponseError(SCIMClientError): +class SCIMResponseException(SCIMClientException): """Base exception for errors happening during response payload validation.""" -class SCIMResponseErrorObject(SCIMResponseError): - """The server response returned a :class:`scim2_models.Error` object. - - Those errors are only raised when the :code:`raise_scim_errors` parameter is :data:`True`. - - :param error: The :class:`~scim2_models.Error` object returned by the server. - """ - - def __init__(self, error: "Error", *args: Any, **kwargs: Any) -> None: - self._error = error - parts = [] - if error.scim_type: - parts.append(error.scim_type + ":") - if error.detail: - parts.append(error.detail) - message = " ".join(parts) if parts else "SCIM Error" - super().__init__(message, *args, **kwargs) - - def to_error(self) -> "Error": - """Return the :class:`~scim2_models.Error` object returned by the server.""" - return self._error - - -class UnexpectedStatusCode(SCIMResponseError): - """Error raised when a server returned an unexpected status code for a given :class:`~scim2_models.Context`.""" +class UnexpectedStatusCodeException(SCIMResponseException): + """Exception raised when a server returned an unexpected status code.""" def __init__(self, status_code: int, *args: Any, **kwargs: Any) -> None: message = kwargs.pop( @@ -102,37 +54,105 @@ def __init__(self, status_code: int, *args: Any, **kwargs: Any) -> None: super().__init__(message, *args, **kwargs) -class UnexpectedContentType(SCIMResponseError): - """Error raised when a server returned an unexpected `Content-Type` header in a response.""" +class UnexpectedContentTypeException(SCIMResponseException): + """Exception raised when a server returned an unexpected `Content-Type` header.""" def __init__(self, content_type: str, *args: Any, **kwargs: Any) -> None: message = kwargs.pop("message", f"Unexpected content type: {content_type}") super().__init__(message, *args, **kwargs) -class UnexpectedContentFormat(SCIMResponseError): - """Error raised when a server returned a response in a non-JSON format.""" +class UnexpectedContentFormatException(SCIMResponseException): + """Exception raised when a server returned a response in a non-JSON format.""" def __init__(self, *args: Any, **kwargs: Any) -> None: message = kwargs.pop("message", "Unexpected response content format") super().__init__(message, *args, **kwargs) -class ResponsePayloadValidationError(SCIMResponseError): - """Error raised when the server returned a payload that cannot be validated. +class ResponsePayloadValidationException(SCIMResponseException): + """Exception raised when the server returned a payload that cannot be validated. - This error is raised when a :class:`pydantic.ValidationError` has been caught + This exception is raised when a :class:`pydantic.ValidationError` has been caught while validating the server response payload. - The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`. + The original :class:`~pydantic.ValidationError` is available with + :attr:`~BaseException.__cause__`. .. code-block:: python try: scim.query(User, "foobar") - except ResponsePayloadValidationError as exc: + except ResponsePayloadValidationException as exc: print("Original validation error cause", exc.__cause__) """ def __init__(self, *args: Any, **kwargs: Any) -> None: message = kwargs.pop("message", "Server response payload validation error") super().__init__(message, *args, **kwargs) + + +def server_error_exception( + error: Error, scim_ctx: Context | None = None +) -> SCIMException: + """Build the exception matching an :class:`~scim2_models.Error` object returned by a server. + + The error object is available with the ``error`` attribute, and the raw + response with the ``source`` attribute. + """ + exc = SCIMException.from_error(error, scim_ctx=scim_ctx) + + # from_error() rebuilds 'status' and 'scim_type' from the exception class, so + # anything the server sent that has no matching class is lost. The values that + # were actually received are restored as instance attributes, which 'to_error()' + # reads back. This whole function can go once the minimum scim2-models version + # keeps the error object itself. + if error.status is not None: + exc.status = error.status + exc.scim_type = error.scim_type or "" + exc.error = error # type: ignore[attr-defined] + return exc + + +def request_validation_exception( + exc: ValidationError, scim_ctx: Context +) -> SCIMException: + """Build the exception matching an invalid request payload. + + The original :class:`~pydantic.ValidationError` is available with + :attr:`~BaseException.__cause__`, and describes every invalid attribute. + """ + errors = Error.from_validation_errors(exc) + scim_exc = SCIMException.from_error(errors[0], scim_ctx=scim_ctx) + if sys.version_info >= (3, 11): # pragma: no cover + scim_exc.add_note(str(exc)) + return scim_exc + + +_DEPRECATED_ALIASES: dict[str, type[SCIMClientException]] = { + "SCIMClientError": SCIMClientException, + "SCIMResponseError": SCIMResponseException, + "RequestNetworkError": RequestNetworkException, + "UnexpectedStatusCode": UnexpectedStatusCodeException, + "UnexpectedContentType": UnexpectedContentTypeException, + "UnexpectedContentFormat": UnexpectedContentFormatException, + "ResponsePayloadValidationError": ResponsePayloadValidationException, +} + + +def deprecated_alias(name: str) -> type[SCIMClientException]: + """Return the exception a deprecated name points at, and warn about the rename.""" + replacement = _DEPRECATED_ALIASES[name] + warnings.warn( + f"{name} is deprecated, use {replacement.__name__} instead. " + "It will be removed in version 0.9.", + DeprecationWarning, + stacklevel=3, + ) + return replacement + + +def __getattr__(name: str) -> type[SCIMClientException]: + if name not in _DEPRECATED_ALIASES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + return deprecated_alias(name) diff --git a/tests/engines/test_httpx2.py b/tests/engines/test_httpx2.py index 11bbef8..9c3471d 100644 --- a/tests/engines/test_httpx2.py +++ b/tests/engines/test_httpx2.py @@ -5,6 +5,7 @@ import pytest from scim2_models import PatchOp from scim2_models import PatchOperation +from scim2_models import SCIMException from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig @@ -12,7 +13,6 @@ from scim2_client.engines.httpx2 import AsyncSCIMClient from scim2_client.engines.httpx2 import Client from scim2_client.engines.httpx2 import SyncSCIMClient -from scim2_client.errors import SCIMResponseErrorObject scim2_server = pytest.importorskip("scim2_server") from scim2_server.backend import InMemoryBackend # noqa: E402 @@ -93,7 +93,7 @@ def test_sync_engine(server): assert queried_user.display_name == "patched name" scim_client.delete(User, response_user.id) - with pytest.raises(SCIMResponseErrorObject): + with pytest.raises(SCIMException): scim_client.query(User, response_user.id) @@ -152,5 +152,5 @@ async def test_async_engine(server): assert queried_user.display_name == "async patched name" await scim_client.delete(User, response_user.id) - with pytest.raises(SCIMResponseErrorObject): + with pytest.raises(SCIMException): await scim_client.query(User, response_user.id) diff --git a/tests/engines/test_httpx_compatibility.py b/tests/engines/test_httpx_compatibility.py index a93b0b1..c11323f 100644 --- a/tests/engines/test_httpx_compatibility.py +++ b/tests/engines/test_httpx_compatibility.py @@ -10,7 +10,7 @@ from scim2_client.engines.httpx2 import AsyncSCIMClient from scim2_client.engines.httpx2 import SyncSCIMClient from scim2_client.engines.httpx2 import _request_error_classes -from scim2_client.errors import RequestNetworkError +from scim2_client.errors import RequestNetworkException requires_httpx2 = pytest.mark.skipif( engine.Client is httpx.Client, reason="httpx2 is not installed" @@ -68,13 +68,13 @@ async def test_httpx_async_client_is_deprecated(httpserver): @requires_httpx2 def test_network_errors_of_httpx_clients_are_converted(httpserver): - """Network errors raised by a httpx client become RequestNetworkError.""" + """Network errors raised by a httpx client become RequestNetworkException.""" with httpx.Client(base_url=f"http://localhost:{httpserver.port}") as client: with pytest.warns(DeprecationWarning): scim_client = SyncSCIMClient(client, resource_models=[User]) with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): scim_client.query(url="http://invalid.test") diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py index 1cd07bb..c2572dd 100644 --- a/tests/engines/test_werkzeug.py +++ b/tests/engines/test_werkzeug.py @@ -2,6 +2,7 @@ from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import ResponseParameters +from scim2_models import SCIMException from scim2_models import SearchRequest from scim2_models import User from werkzeug.test import Client @@ -9,8 +10,7 @@ from werkzeug.wrappers import Response from scim2_client.engines.werkzeug import TestSCIMClient -from scim2_client.errors import SCIMResponseErrorObject -from scim2_client.errors import UnexpectedContentFormat +from scim2_client.errors import UnexpectedContentFormatException scim2_server = pytest.importorskip("scim2_server") from scim2_server.backend import InMemoryBackend # noqa: E402 @@ -74,7 +74,7 @@ def test_werkzeug_engine(scim_client): assert queried_user.display_name == "werkzeug patched" scim_client.delete(User, response_user.id) - with pytest.raises(SCIMResponseErrorObject): + with pytest.raises(SCIMException): scim_client.query(User, response_user.id) @@ -91,7 +91,7 @@ def test_werkzeug_query_with_attributes(scim_client): def test_no_json(): - """Test that pages that do not return JSON raise an UnexpectedContentFormat error.""" + """Test that pages that do not return JSON raise an UnexpectedContentFormatException error.""" @Request.application def application(request): @@ -100,10 +100,29 @@ def application(request): werkzeug_client = Client(application) scim_client = TestSCIMClient(client=werkzeug_client, resource_models=(User,)) scim_client.register_naive_resource_types() - with pytest.raises(UnexpectedContentFormat): + with pytest.raises(UnexpectedContentFormatException): scim_client.query(url="/") +def test_invalid_payload(): + """Test that a response with invalid SCIM payload raises a ResponsePayloadValidationException.""" + from scim2_client.errors import ResponsePayloadValidationException + + @Request.application + def application(request): + # Return valid JSON but with invalid SCIM data (missing required fields) + return Response( + '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "active": "not-a-bool"}', + content_type="application/scim+json", + ) + + werkzeug_client = Client(application) + scim_client = TestSCIMClient(client=werkzeug_client, resource_models=(User,)) + scim_client.register_naive_resource_types() + with pytest.raises(ResponsePayloadValidationException): + scim_client.query(url="/Users/1234") + + def test_environ(scim_client): @Request.application def application(request): diff --git a/tests/test_create.py b/tests/test_create.py index de9d1e4..9a5ee51 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -2,16 +2,14 @@ import pytest from scim2_models import Error +from scim2_models import InvalidValueException from scim2_models import Meta from scim2_models import Resource +from scim2_models import SCIMException from scim2_models import User -from scim2_client import RequestNetworkError -from scim2_client import RequestPayloadValidationError -from scim2_client import SCIMClientError -from scim2_client import SCIMRequestError -from scim2_client import SCIMResponseErrorObject -from scim2_client import UnexpectedStatusCode +from scim2_client import RequestNetworkException +from scim2_client import UnexpectedStatusCodeException def test_create_user(httpserver, sync_client): @@ -120,7 +118,7 @@ def test_create_dict_user_bad_schema(httpserver, sync_client): } with pytest.raises( - SCIMClientError, match="Cannot guess resource type from the payload" + InvalidValueException, match="Cannot guess resource type from the payload" ): sync_client.create(user_request) @@ -217,7 +215,7 @@ def test_create_error_exception_carries_the_response(httpserver, sync_client): status=409, ) - with pytest.raises(SCIMResponseErrorObject) as exc_info: + with pytest.raises(SCIMException) as exc_info: sync_client.create(User(user_name="bjensen@example.com")) assert exc_info.value.source.status_code == 409 @@ -243,7 +241,7 @@ def test_no_200(httpserver, sync_client): user_request = User(user_name="bjensen@example.com") - with pytest.raises(UnexpectedStatusCode): + with pytest.raises(UnexpectedStatusCodeException): sync_client.create(user_request) sync_client.create(user_request, expected_status_codes=None) sync_client.create(user_request, expected_status_codes=[200, 201]) @@ -279,15 +277,13 @@ class MyResource(Resource): __schema__ = "urn:ietf:params:scim:schemas:core:2.0:MyResource" display_name: str | None = None - with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + with pytest.raises(InvalidValueException, match=r"Unknown resource type"): sync_client.create(MyResource(display_name="foobar")) def test_request_validation_error(sync_client): - """Test that incorrect input raise a RequestPayloadValidationError.""" - with pytest.raises( - RequestPayloadValidationError, match="Server request payload validation error" - ): + """Test that incorrect input raise a SCIMException.""" + with pytest.raises(SCIMException): sync_client.create( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -297,9 +293,9 @@ def test_request_validation_error(sync_client): def test_request_network_error(sync_client): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" user_request = User(user_name="bjensen@example.com") with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): sync_client.create(user_request, url="http://invalid.test") diff --git a/tests/test_delete.py b/tests/test_delete.py index 081b1d4..a132990 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -1,10 +1,10 @@ import pytest from scim2_models import Error +from scim2_models import InvalidValueException from scim2_models import Resource from scim2_models import User -from scim2_client import RequestNetworkError -from scim2_client import SCIMRequestError +from scim2_client import RequestNetworkException class UnregisteredResource(Resource): @@ -58,7 +58,7 @@ def test_errors(httpserver, code, sync_client): def test_invalid_resource_model(httpserver, sync_client): """Test that resource_models passed to the method must be part of SCIMClient.resource_models.""" - with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + with pytest.raises(InvalidValueException, match=r"Unknown resource type"): sync_client.delete(UnregisteredResource, id="foobar") @@ -86,8 +86,8 @@ def test_dont_check_response_payload(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): sync_client.delete(User, "anything", url="http://invalid.test") diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py new file mode 100644 index 0000000..1113015 --- /dev/null +++ b/tests/test_deprecations.py @@ -0,0 +1,53 @@ +import pytest +from scim2_models import User + +import scim2_client +from scim2_client import errors + +RENAMED_EXCEPTIONS = [ + ("SCIMClientError", "SCIMClientException"), + ("SCIMResponseError", "SCIMResponseException"), + ("RequestNetworkError", "RequestNetworkException"), + ("UnexpectedStatusCode", "UnexpectedStatusCodeException"), + ("UnexpectedContentType", "UnexpectedContentTypeException"), + ("UnexpectedContentFormat", "UnexpectedContentFormatException"), + ("ResponsePayloadValidationError", "ResponsePayloadValidationException"), +] + + +@pytest.mark.parametrize(("old", "new"), RENAMED_EXCEPTIONS) +@pytest.mark.parametrize("module", [scim2_client, errors]) +def test_old_exception_names_are_the_renamed_classes(module, old, new): + """Test that the old exception names warn about the rename and point at the new classes.""" + with pytest.warns(DeprecationWarning, match=f"{old} is deprecated, use {new}"): + alias = getattr(module, old) + + assert alias is getattr(module, new) + + +@pytest.mark.parametrize("module", [scim2_client, errors]) +def test_unknown_names_are_not_served(module): + """Test that names that were never part of the API are still unknown.""" + unknown_name = "Foobar" + with pytest.raises(AttributeError, match="has no attribute 'Foobar'"): + getattr(module, unknown_name) + + +def test_old_exception_names_catch_the_exceptions_the_client_raises( + httpserver, sync_client +): + """Test that code written against the old names still catches the client exceptions.""" + httpserver.expect_request("/Users").respond_with_json( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "2819c223-7f76-453a-919d-413861904646", + "userName": "bjensen@example.com", + }, + status=299, + ) + + with pytest.warns(DeprecationWarning): + unexpected_status_code = errors.UnexpectedStatusCode + + with pytest.raises(unexpected_status_code): + sync_client.create(User(user_name="bjensen@example.com")) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..d0d8b3b --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,32 @@ +from scim2_models import Error +from scim2_models import UniquenessException + +from scim2_client.errors import server_error_exception + + +def test_server_error_exception_uses_the_class_matching_the_scim_type(): + """Test that a known scimType is turned into its dedicated exception.""" + error = Error(status=409, scim_type="uniqueness", detail="User already exists") + exc = server_error_exception(error) + + assert isinstance(exc, UniquenessException) + assert exc.to_error() == error + + +def test_server_error_exception_keeps_unknown_status_and_scim_type(): + """Test that values scim2-models has no exception class for are preserved.""" + error = Error(status=429, scim_type="tooManyRequests", detail="Slow down") + exc = server_error_exception(error) + + assert exc.status == 429 + assert exc.scim_type == "tooManyRequests" + assert exc.to_error() == error + + +def test_server_error_exception_without_status(): + """Test that an error object carrying no status keeps the default one.""" + error = Error(detail="Something happened") + exc = server_error_exception(error) + + assert exc.status == 400 + assert exc.error is error diff --git a/tests/test_modify.py b/tests/test_modify.py index d82d825..416e23f 100644 --- a/tests/test_modify.py +++ b/tests/test_modify.py @@ -1,14 +1,14 @@ import pytest from scim2_models import Error from scim2_models import Group +from scim2_models import InvalidValueException from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import ResourceType +from scim2_models import SCIMException from scim2_models import User -from scim2_client import RequestNetworkError -from scim2_client import RequestPayloadValidationError -from scim2_client import SCIMRequestError +from scim2_client import RequestNetworkException def test_modify_user_200(httpserver, sync_client): @@ -327,7 +327,7 @@ def test_invalid_resource_model(httpserver, sync_client): ) patch_op = PatchOp[Group](operations=[operation]) - with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + with pytest.raises(InvalidValueException, match=r"Unknown resource type"): sync_client.modify(Group, "some-id", patch_op) @@ -335,7 +335,7 @@ 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), + (SCIMException, ValueError, TypeError), match=r"(?i)(validation|invalid|error)", ): # Create a PatchOp with invalid enum value by bypassing normal validation @@ -348,14 +348,14 @@ def test_request_validation_error(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" 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" + RequestNetworkException, match="Network error happened during request" ): sync_client.modify(User, "some-id", patch_op, url="http://invalid.test") @@ -422,8 +422,5 @@ def test_modify_validation_error(httpserver, sync_client): invalid_patch_op.model_dump.side_effect = exc_info.value - with pytest.raises( - RequestPayloadValidationError, - match="Server request payload validation error", - ): + with pytest.raises(SCIMException): sync_client.modify(User, "some-id", invalid_patch_op) diff --git a/tests/test_query.py b/tests/test_query.py index e4e215d..48dd442 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -3,24 +3,24 @@ import pytest from scim2_models import Error from scim2_models import Group +from scim2_models import InvalidValueException from scim2_models import ListResponse from scim2_models import Meta from scim2_models import Resource from scim2_models import ResourceType from scim2_models import ResponseParameters +from scim2_models import SCIMException from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig +from scim2_models import UniquenessException from scim2_models import User -from scim2_client import SCIMRequestError -from scim2_client.errors import RequestNetworkError -from scim2_client.errors import ResponsePayloadValidationError -from scim2_client.errors import SCIMClientError -from scim2_client.errors import SCIMResponseError -from scim2_client.errors import SCIMResponseErrorObject -from scim2_client.errors import UnexpectedContentFormat -from scim2_client.errors import UnexpectedContentType -from scim2_client.errors import UnexpectedStatusCode +from scim2_client.errors import RequestNetworkException +from scim2_client.errors import ResponsePayloadValidationException +from scim2_client.errors import SCIMResponseException +from scim2_client.errors import UnexpectedContentFormatException +from scim2_client.errors import UnexpectedContentTypeException +from scim2_client.errors import UnexpectedStatusCodeException @pytest.fixture @@ -326,19 +326,19 @@ def test_user_with_invalid_id(sync_client): def test_raise_scim_errors(sync_client): """Test that querying an user with an invalid id raises an exception.""" with pytest.raises( - SCIMResponseErrorObject, + SCIMException, match="Resource unknown not found", ) as exc_info: sync_client.query(User, "unknown", raise_scim_errors=True) - assert exc_info.value.to_error() == Error( - detail="Resource unknown not found", status=404 - ) + expected = Error(detail="Resource unknown not found", status=404) + assert exc_info.value.error == expected + assert exc_info.value.to_error() == expected def test_query_error_exception_carries_the_response(sync_client): """Test that a query exception gives access to the response that carried the error.""" - with pytest.raises(SCIMResponseErrorObject) as exc_info: + with pytest.raises(SCIMException) as exc_info: sync_client.query(User, "unknown", raise_scim_errors=True) assert exc_info.value.source.status_code == 404 @@ -347,8 +347,8 @@ def test_query_error_exception_carries_the_response(sync_client): def test_raise_scim_errors_with_scim_type(sync_client): """Test that the exception message includes scim_type when present.""" with pytest.raises( - SCIMResponseErrorObject, - match="uniqueness: User already exists", + UniquenessException, + match="User already exists", ) as exc_info: sync_client.query(User, "conflict", raise_scim_errors=True) @@ -359,13 +359,11 @@ def test_raise_scim_errors_with_scim_type(sync_client): def test_raise_scim_errors_without_detail(sync_client): """Test that the exception works when the error has no detail.""" - with pytest.raises( - SCIMResponseErrorObject, - match="SCIM Error", - ) as exc_info: + with pytest.raises(SCIMException) as exc_info: sync_client.query(User, "no-detail", raise_scim_errors=True) - assert exc_info.value.to_error() == Error(status=500) + assert exc_info.value.error == Error(status=500) + assert exc_info.value.to_error().status == 500 def test_all_users(sync_client): @@ -456,12 +454,12 @@ class Foobar(Resource): def test_bad_resource_model(sync_client): - """Test querying a resource unknown from the client raise a SCIMResponseError.""" + """Test querying a resource unknown from the client raise a SCIMResponseException.""" sync_client.resource_models = (User,) sync_client.resource_types = [ResourceType.from_resource(User)] with pytest.raises( - SCIMResponseError, + SCIMResponseException, match="Expected type User but got unknown resource with schemas: urn:ietf:params:scim:schemas:core:2.0:Group", ): sync_client.query(User, "its-a-group") @@ -478,26 +476,27 @@ def test_all(sync_client): def test_all_unexpected_type(sync_client): - """Test retrieving a payload for an object which type has not been passed in parameters raise a ResponsePayloadValidationError.""" + """Test retrieving a payload for an object which type has not been passed in parameters raise a ResponsePayloadValidationException.""" sync_client.resource_models = (User,) sync_client.resource_types = [ResourceType.from_resource(User)] with pytest.raises( - ResponsePayloadValidationError, match="Server response payload validation error" + ResponsePayloadValidationException, + match="Server response payload validation error", ): sync_client.query() def test_response_is_not_json(sync_client): """Test situations where servers return an invalid JSON object.""" - with pytest.raises(UnexpectedContentFormat): + with pytest.raises(UnexpectedContentFormatException): sync_client.query(User, "not-json") def test_not_a_scim_object(sync_client): """Test retrieving a valid JSON object without a schema.""" with pytest.raises( - SCIMResponseError, + SCIMResponseException, match="Expected type User but got undefined object with no schema", ): sync_client.query(User, "not-a-scim-object") @@ -513,7 +512,7 @@ def test_dont_check_response_payload(sync_client): def test_response_bad_status_code(sync_client): """Test situations where servers return an invalid status code.""" - with pytest.raises(UnexpectedStatusCode): + with pytest.raises(UnexpectedStatusCodeException): sync_client.query(User, "status-201") sync_client.query(User, "status-201", expected_status_codes=None) @@ -526,7 +525,7 @@ def test_response_content_type_with_charset(sync_client): def test_response_bad_content_type(sync_client): """Test situations where servers return an invalid content-type response.""" - with pytest.raises(UnexpectedContentType): + with pytest.raises(UnexpectedContentTypeException): sync_client.query(User, "bad-content-type") @@ -667,7 +666,7 @@ def test_invalid_resource_model(sync_client): sync_client.resource_models = (User,) sync_client.resource_types = [ResourceType.from_resource(User)] - with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + with pytest.raises(InvalidValueException, match=r"Unknown resource type"): sync_client.query(Group) @@ -680,14 +679,14 @@ def test_service_provider_config_endpoint(sync_client): def test_service_provider_config_endpoint_with_an_id(sync_client): """Test that querying the /ServiceProviderConfig with an id raise an exception.""" with pytest.raises( - SCIMClientError, match="ServiceProviderConfig cannot have an id" + InvalidValueException, match="ServiceProviderConfig cannot have an id" ): sync_client.query(ServiceProviderConfig, "dummy") def test_request_network_error(sync_client): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): sync_client.query(url="http://invalid.test") diff --git a/tests/test_replace.py b/tests/test_replace.py index 51e2fe0..dce861f 100644 --- a/tests/test_replace.py +++ b/tests/test_replace.py @@ -3,14 +3,13 @@ import pytest from scim2_models import Error from scim2_models import Group +from scim2_models import InvalidValueException from scim2_models import Meta from scim2_models import ResourceType +from scim2_models import SCIMException from scim2_models import User -from scim2_client import RequestNetworkError -from scim2_client import RequestPayloadValidationError -from scim2_client import SCIMClientError -from scim2_client import SCIMRequestError +from scim2_client import RequestNetworkException def test_replace_user(httpserver, sync_client): @@ -122,7 +121,7 @@ def test_replace_user_dict_bad_schema(httpserver, sync_client): } with pytest.raises( - SCIMClientError, match="Cannot guess resource type from the payload" + InvalidValueException, match="Cannot guess resource type from the payload" ): sync_client.replace(payload) @@ -277,7 +276,7 @@ def test_user_with_no_id(httpserver, sync_client): ), ) - with pytest.raises(SCIMClientError, match="Resource must have an id"): + with pytest.raises(InvalidValueException, match="Resource must have an id"): sync_client.replace(user) @@ -286,15 +285,13 @@ def test_invalid_resource_model(httpserver, sync_client): sync_client.resource_models = (User,) sync_client.resource_types = [ResourceType.from_resource(User)] - with pytest.raises(SCIMRequestError, match=r"Unknown resource type"): + with pytest.raises(InvalidValueException, match=r"Unknown resource type"): sync_client.replace(Group(display_name="foobar")) def test_request_validation_error(httpserver, sync_client): - """Test that incorrect input raise a RequestPayloadValidationError.""" - with pytest.raises( - RequestPayloadValidationError, match="Server request payload validation error" - ): + """Test that incorrect input raise a SCIMException.""" + with pytest.raises(SCIMException): sync_client.replace( { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], @@ -304,9 +301,9 @@ def test_request_validation_error(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" user_request = User(user_name="bjensen@example.com", id="anything") with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): sync_client.replace(user_request, url="http://invalid.test") diff --git a/tests/test_search.py b/tests/test_search.py index d3645fa..44485d2 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -8,7 +8,7 @@ from scim2_models import SearchRequest from scim2_models import User -from scim2_client import RequestNetworkError +from scim2_client import RequestNetworkException from scim2_client.engines.httpx2 import Client from scim2_client.engines.httpx2 import SyncSCIMClient @@ -244,10 +244,10 @@ def test_errors(httpserver, code): def test_request_network_error(httpserver): - """Test that httpx2 exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkException.""" client = Client(base_url=f"http://localhost:{httpserver.port}") scim_client = SyncSCIMClient(client, resource_models=(User,)) with pytest.raises( - RequestNetworkError, match="Network error happened during request" + RequestNetworkException, match="Network error happened during request" ): scim_client.search(url="http://invalid.test") diff --git a/tests/test_utils.py b/tests/test_utils.py index 5947000..6da7cc6 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,13 +1,13 @@ import pytest from scim2_models import EnterpriseUser from scim2_models import Group +from scim2_models import InvalidValueException from scim2_models import Resource from scim2_models import ResourceType from scim2_models import Schema from scim2_models import ServiceProviderConfig from scim2_models import User -from scim2_client import SCIMRequestError from scim2_client.engines.httpx2 import SyncSCIMClient @@ -32,7 +32,7 @@ class Foobar(Resource): # This one is special as it does not take an ending 's' assert client.resource_endpoint(ServiceProviderConfig) == "/ServiceProviderConfig" - with pytest.raises(SCIMRequestError): + with pytest.raises(InvalidValueException): client.resource_endpoint(Foobar)