diff --git a/doc/changelog.rst b/doc/changelog.rst index 099323e..c039473 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -9,6 +9,8 @@ Added - Resources define their schema URN with a ``__schema__`` classvar instead of a ``schemas`` default value. :issue:`110` - Validation that the base schema is present in ``schemas`` during SCIM context validation. - Validation that extension schemas are known during SCIM context validation. +- Introduce SCIM exceptions hierarchy (:class:`~scim2_models.SCIMException` and subclasses) corresponding to RFC 7644 error types. :issue:`103` +- :meth:`Error.from_validation_error ` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`. Changed ^^^^^^^ @@ -17,6 +19,7 @@ Changed Deprecated ^^^^^^^^^^ - Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead. +- ``Error.make_*_error()`` methods are deprecated. Use ``.to_error()`` instead. [0.5.2] - 2026-01-22 -------------------- diff --git a/doc/tutorial.rst b/doc/tutorial.rst index aa32eb6..6472fa5 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -275,26 +275,79 @@ Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].em ... } -Pre-defined Error objects -========================= +Errors and Exceptions +===================== -:rfc:`RFC7643 §3.12 <7643#section-3.12>` pre-defined errors are usable. +scim2-models provides a hierarchy of exceptions corresponding to :rfc:`RFC7644 §3.12 <7644#section-3.12>` error types. +Each exception can be converted to an :class:`~scim2_models.Error` response object or used in Pydantic validators. + +Raising exceptions +~~~~~~~~~~~~~~~~~~ + +Exceptions are named after their ``scimType`` value: .. code-block:: python - >>> from scim2_models import Error + >>> from scim2_models import InvalidPathException, PathNotFoundException - >>> error = Error.make_invalid_path_error() - >>> dump = error.model_dump() - >>> assert dump == { - ... 'detail': 'The "path" attribute was invalid or malformed (see Figure 7 of RFC7644).', - ... 'schemas': ['urn:ietf:params:scim:api:messages:2.0:Error'], - ... 'scimType': 'invalidPath', - ... 'status': '400' - ... } + >>> raise InvalidPathException(path="invalid..path") + Traceback (most recent call last): + ... + scim2_models.exceptions.InvalidPathException: The path attribute was invalid or malformed + + >>> raise PathNotFoundException(path="unknownAttr") + Traceback (most recent call last): + ... + scim2_models.exceptions.PathNotFoundException: The specified path references a non-existent field + +Converting to Error response +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use :meth:`~scim2_models.SCIMException.to_error` to convert an exception to an :class:`~scim2_models.Error` response: + +.. code-block:: python + + >>> from scim2_models import InvalidPathException + + >>> exc = InvalidPathException(path="invalid..path") + >>> error = exc.to_error() + >>> error.status + 400 + >>> error.scim_type + 'invalidPath' -The exhaustive list is available in the :class:`reference `. +Converting from ValidationError +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Use :meth:`Error.from_validation_error ` to convert a single Pydantic error to an :class:`~scim2_models.Error`: + +.. code-block:: python + + >>> from pydantic import ValidationError + >>> from scim2_models import Error, User + >>> from scim2_models.base import Context + + >>> try: + ... User.model_validate({"userName": None}, context={"scim": Context.RESOURCE_CREATION_REQUEST}) + ... except ValidationError as exc: + ... error = Error.from_validation_error(exc.errors()[0]) + >>> error.scim_type + 'invalidValue' + +Use :meth:`Error.from_validation_errors ` to convert all errors at once: + +.. code-block:: python + + >>> try: + ... User.model_validate({"userName": 123, "displayName": 456}) + ... except ValidationError as exc: + ... errors = Error.from_validation_errors(exc) + >>> len(errors) + 2 + >>> [e.detail for e in errors] + ['Input should be a valid string: userName', 'Input should be a valid string: displayName'] + +The exhaustive list of exceptions is available in the :class:`reference `. Custom models ============= diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 3a2d916..7da28c5 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -7,6 +7,18 @@ from .attributes import MultiValuedComplexAttribute from .base import BaseModel from .context import Context +from .exceptions import InvalidFilterException +from .exceptions import InvalidPathException +from .exceptions import InvalidSyntaxException +from .exceptions import InvalidValueException +from .exceptions import InvalidVersionException +from .exceptions import MutabilityException +from .exceptions import NoTargetException +from .exceptions import PathNotFoundException +from .exceptions import SCIMException +from .exceptions import SensitiveException +from .exceptions import TooManyException +from .exceptions import UniquenessException from .messages.bulk import BulkOperation from .messages.bulk import BulkRequest from .messages.bulk import BulkResponse @@ -17,10 +29,7 @@ from .messages.patch_op import PatchOperation from .messages.search_request import SearchRequest from .path import URN -from .path import InvalidPathError from .path import Path -from .path import PathError -from .path import PathNotFoundError from .reference import ExternalReference from .reference import Reference from .reference import URIReference @@ -84,17 +93,22 @@ "GroupMember", "GroupMembership", "Im", - "InvalidPathError", + "InvalidFilterException", + "InvalidPathException", + "InvalidSyntaxException", + "InvalidValueException", + "InvalidVersionException", "ListResponse", "Manager", "Message", "Meta", "Mutability", + "MutabilityException", "MultiValuedComplexAttribute", "Name", + "NoTargetException", "Path", - "PathError", - "PathNotFoundError", + "PathNotFoundException", "Patch", "PatchOp", "PatchOperation", @@ -106,12 +120,16 @@ "ResourceType", "Returned", "Role", + "SCIMException", "Schema", "SchemaExtension", "SearchRequest", + "SensitiveException", "ServiceProviderConfig", "Sort", + "TooManyException", "Uniqueness", + "UniquenessException", "URIReference", "URN", "User", diff --git a/scim2_models/exceptions.py b/scim2_models/exceptions.py new file mode 100644 index 0000000..7f9d9da --- /dev/null +++ b/scim2_models/exceptions.py @@ -0,0 +1,265 @@ +"""SCIM exceptions corresponding to RFC 7644 error types. + +This module provides a hierarchy of exceptions that map to SCIM protocol errors. +Each exception can be converted to a :class:`~scim2_models.Error` response object +or to a :class:`~pydantic_core.PydanticCustomError` for use in Pydantic validators. +""" + +from typing import TYPE_CHECKING +from typing import Any + +from pydantic_core import PydanticCustomError + +if TYPE_CHECKING: + from .messages.error import Error + + +class SCIMException(Exception): + """Base exception for SCIM protocol errors. + + Each subclass corresponds to a scimType defined in :rfc:`RFC 7644 Table 9 <7644#section-3.12>`. + """ + + status: int = 400 + scim_type: str = "" + _default_detail: str = "A SCIM error occurred" + + def __init__(self, *, detail: str | None = None, **context: Any): + self.context = context + self._detail = detail + super().__init__(detail or self._default_detail) + + @property + def detail(self) -> str: + """The error detail message.""" + return self._detail or self._default_detail + + def to_error(self) -> "Error": + """Convert this exception to a SCIM Error response object.""" + from .messages.error import Error + + return Error( + status=self.status, + scim_type=self.scim_type or None, + detail=str(self), + ) + + def as_pydantic_error(self) -> PydanticCustomError: + """Convert to PydanticCustomError for use in Pydantic validators.""" + return PydanticCustomError( + f"scim_{self.scim_type}" if self.scim_type else "scim_error", + str(self), + {"scim_type": self.scim_type, "status": self.status, **self.context}, + ) + + +class InvalidFilterException(SCIMException): + """The specified filter syntax was invalid. + + Corresponds to scimType ``invalidFilter`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.4.2.2 <7644#section-3.4.2.2>` + """ + + status = 400 + scim_type = "invalidFilter" + _default_detail = ( + "The specified filter syntax was invalid, " + "or the specified attribute and filter comparison combination is not supported" + ) + + def __init__(self, *, filter: str | None = None, **kw: Any): + self.filter = filter + super().__init__(**kw) + + +class TooManyException(SCIMException): + """The specified filter yields too many results. + + Corresponds to scimType ``tooMany`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.4.2.2 <7644#section-3.4.2.2>` + """ + + status = 400 + scim_type = "tooMany" + _default_detail = ( + "The specified filter yields many more results " + "than the server is willing to calculate or process" + ) + + +class UniquenessException(SCIMException): + """One or more attribute values are already in use or reserved. + + Corresponds to scimType ``uniqueness`` with HTTP status 409. + + :rfc:`RFC 7644 Section 3.3.1 <7644#section-3.3.1>` + """ + + status = 409 + scim_type = "uniqueness" + _default_detail = ( + "One or more of the attribute values are already in use or are reserved" + ) + + def __init__( + self, *, attribute: str | None = None, value: Any | None = None, **kw: Any + ): + self.attribute = attribute + self.value = value + super().__init__(**kw) + + +class MutabilityException(SCIMException): + """The attempted modification is not compatible with the attribute's mutability. + + Corresponds to scimType ``mutability`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>` + """ + + status = 400 + scim_type = "mutability" + _default_detail = ( + "The attempted modification is not compatible with the target attribute's " + "mutability or current state" + ) + + def __init__( + self, + *, + attribute: str | None = None, + mutability: str | None = None, + operation: str | None = None, + **kw: Any, + ): + self.attribute = attribute + self.mutability = mutability + self.operation = operation + super().__init__(**kw) + + +class InvalidSyntaxException(SCIMException): + """The request body message structure was invalid. + + Corresponds to scimType ``invalidSyntax`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>` + """ + + status = 400 + scim_type = "invalidSyntax" + _default_detail = ( + "The request body message structure was invalid " + "or did not conform to the request schema" + ) + + +class InvalidPathException(SCIMException): + """The path attribute was invalid or malformed. + + Corresponds to scimType ``invalidPath`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>` + """ + + status = 400 + scim_type = "invalidPath" + _default_detail = "The path attribute was invalid or malformed" + + def __init__(self, *, path: str | None = None, **kw: Any): + self.path = path + super().__init__(**kw) + + +class PathNotFoundException(InvalidPathException): + """The path references a non-existent field. + + This is a specialized form of :class:`InvalidPathException`. + """ + + _default_detail = "The specified path references a non-existent field" + + def __init__(self, *, path: str | None = None, field: str | None = None, **kw: Any): + self.field = field + super().__init__(path=path, **kw) + + def __str__(self) -> str: + if self._detail: + return self._detail + if self.field: + return f"Field not found: {self.field}" + return self._default_detail + + +class NoTargetException(SCIMException): + """The specified path did not yield a target that could be operated on. + + Corresponds to scimType ``noTarget`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>` + """ + + status = 400 + scim_type = "noTarget" + _default_detail = ( + "The specified path did not yield an attribute or attribute value " + "that could be operated on" + ) + + def __init__(self, *, path: str | None = None, **kw: Any): + self.path = path + super().__init__(**kw) + + +class InvalidValueException(SCIMException): + """A required value was missing or the value was not compatible. + + Corresponds to scimType ``invalidValue`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>` + """ + + status = 400 + scim_type = "invalidValue" + _default_detail = ( + "A required value was missing, or the value specified was not compatible " + "with the operation or attribute type, or resource schema" + ) + + def __init__( + self, *, attribute: str | None = None, reason: str | None = None, **kw: Any + ): + self.attribute = attribute + self.reason = reason + super().__init__(**kw) + + +class InvalidVersionException(SCIMException): + """The specified SCIM protocol version is not supported. + + Corresponds to scimType ``invalidVers`` with HTTP status 400. + + :rfc:`RFC 7644 Section 3.13 <7644#section-3.13>` + """ + + status = 400 + scim_type = "invalidVers" + _default_detail = "The specified SCIM protocol version is not supported" + + +class SensitiveException(SCIMException): + """The request cannot be completed due to sensitive information in the URI. + + Corresponds to scimType ``sensitive`` with HTTP status 400. + + :rfc:`RFC 7644 Section 7.5.2 <7644#section-7.5.2>` + """ + + status = 400 + scim_type = "sensitive" + _default_detail = ( + "The specified request cannot be completed, due to the passing of sensitive " + "information in a request URI" + ) diff --git a/scim2_models/messages/error.py b/scim2_models/messages/error.py index 5ad13dd..791df5a 100644 --- a/scim2_models/messages/error.py +++ b/scim2_models/messages/error.py @@ -1,6 +1,11 @@ +import warnings +from collections.abc import Mapping +from collections.abc import Sequence from typing import Annotated +from typing import Any from pydantic import PlainSerializer +from pydantic import ValidationError from ..path import URN from ..utils import _int_to_str @@ -8,7 +13,10 @@ class Error(Message): - """Representation of SCIM API errors.""" + """Representation of SCIM API errors. + + :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>` + """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:Error") @@ -24,7 +32,18 @@ class Error(Message): @classmethod def make_invalid_filter_error(cls) -> "Error": - """Pre-defined error intended to be raised when the specified filter syntax was invalid (does not comply with :rfc:`Figure 1 of RFC7644 <7644#section-3.4.2.2>`), or the specified attribute and filter comparison combination is not supported.""" + """Pre-defined error intended to be raised when the specified filter syntax was invalid (does not comply with :rfc:`Figure 1 of RFC7644 <7644#section-3.4.2.2>`), or the specified attribute and filter comparison combination is not supported. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.InvalidFilterException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_invalid_filter_error is deprecated, use InvalidFilterException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="invalidFilter", @@ -33,7 +52,18 @@ def make_invalid_filter_error(cls) -> "Error": @classmethod def make_too_many_error(cls) -> "Error": - """Pre-defined error intended to be raised when the specified filter yields many more results than the server is willing to calculate or process. For example, a filter such as ``(userName pr)`` by itself would return all entries with a ``userName`` and MAY not be acceptable to the service provider.""" + """Pre-defined error intended to be raised when the specified filter yields many more results than the server is willing to calculate or process. For example, a filter such as ``(userName pr)`` by itself would return all entries with a ``userName`` and MAY not be acceptable to the service provider. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.TooManyException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_too_many_error is deprecated, use TooManyException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="tooMany", @@ -42,7 +72,18 @@ def make_too_many_error(cls) -> "Error": @classmethod def make_uniqueness_error(cls) -> "Error": - """Pre-defined error intended to be raised when One or more of the attribute values are already in use or are reserved.""" + """Pre-defined error intended to be raised when One or more of the attribute values are already in use or are reserved. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.UniquenessException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_uniqueness_error is deprecated, use UniquenessException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=409, scim_type="uniqueness", @@ -51,7 +92,18 @@ def make_uniqueness_error(cls) -> "Error": @classmethod def make_mutability_error(cls) -> "Error": - """Pre-defined error intended to be raised when the attempted modification is not compatible with the target attribute's mutability or current state (e.g., modification of an "immutable" attribute with an existing value).""" + """Pre-defined error intended to be raised when the attempted modification is not compatible with the target attribute's mutability or current state (e.g., modification of an "immutable" attribute with an existing value). + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.MutabilityException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_mutability_error is deprecated, use MutabilityException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="mutability", @@ -60,7 +112,18 @@ def make_mutability_error(cls) -> "Error": @classmethod def make_invalid_syntax_error(cls) -> "Error": - """Pre-defined error intended to be raised when the request body message structure was invalid or did not conform to the request schema.""" + """Pre-defined error intended to be raised when the request body message structure was invalid or did not conform to the request schema. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.InvalidSyntaxException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_invalid_syntax_error is deprecated, use InvalidSyntaxException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="invalidSyntax", @@ -69,7 +132,18 @@ def make_invalid_syntax_error(cls) -> "Error": @classmethod def make_invalid_path_error(cls) -> "Error": - """Pre-defined error intended to be raised when the "path" attribute was invalid or malformed (see :rfc:`Figure 7 of RFC7644 <7644#section-3.5.2>`).""" + """Pre-defined error intended to be raised when the "path" attribute was invalid or malformed (see :rfc:`Figure 7 of RFC7644 <7644#section-3.5.2>`). + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.InvalidPathException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_invalid_path_error is deprecated, use InvalidPathException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="invalidPath", @@ -78,7 +152,18 @@ def make_invalid_path_error(cls) -> "Error": @classmethod def make_no_target_error(cls) -> "Error": - """Pre-defined error intended to be raised when the specified "path" did not yield an attribute or attribute value that could be operated on. This occurs when the specified "path" value contains a filter that yields no match.""" + """Pre-defined error intended to be raised when the specified "path" did not yield an attribute or attribute value that could be operated on. This occurs when the specified "path" value contains a filter that yields no match. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.NoTargetException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_no_target_error is deprecated, use NoTargetException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="noTarget", @@ -87,7 +172,18 @@ def make_no_target_error(cls) -> "Error": @classmethod def make_invalid_value_error(cls) -> "Error": - """Pre-defined error intended to be raised when a required value was missing, or the value specified was not compatible with the operation or attribute type (see :rfc:`Section 2.2 of RFC7643 <7643#section-2.2>`), or resource schema (see :rfc:`Section 4 of RFC7643 <7643#section-4>`).""" + """Pre-defined error intended to be raised when a required value was missing, or the value specified was not compatible with the operation or attribute type (see :rfc:`Section 2.2 of RFC7643 <7643#section-2.2>`), or resource schema (see :rfc:`Section 4 of RFC7643 <7643#section-4>`). + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.InvalidValueException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_invalid_value_error is deprecated, use InvalidValueException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="invalidValue", @@ -96,7 +192,18 @@ def make_invalid_value_error(cls) -> "Error": @classmethod def make_invalid_version_error(cls) -> "Error": - """Pre-defined error intended to be raised when the specified SCIM protocol version is not supported (see :rfc:`Section 3.13 of RFC7644 <7644#section-3.13>`).""" + """Pre-defined error intended to be raised when the specified SCIM protocol version is not supported (see :rfc:`Section 3.13 of RFC7644 <7644#section-3.13>`). + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.InvalidVersionException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_invalid_version_error is deprecated, use InvalidVersionException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="invalidVers", @@ -105,9 +212,73 @@ def make_invalid_version_error(cls) -> "Error": @classmethod def make_sensitive_error(cls) -> "Error": - """Pre-defined error intended to be raised when the specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See :rfc:`Section 7.5.2 of RFC7644 <7644#section-7.5.2>`.""" + """Pre-defined error intended to be raised when the specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See :rfc:`Section 7.5.2 of RFC7644 <7644#section-7.5.2>`. + + .. deprecated:: 0.6.0 + Use :class:`~scim2_models.SensitiveException` instead. + Will be removed in 0.7.0. + """ + warnings.warn( + "make_sensitive_error is deprecated, use SensitiveException().to_error() instead. " + "Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=2, + ) return Error( status=400, scim_type="sensitive", detail="""The specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See Section 7.5.2. of RFC7644""", ) + + @classmethod + def from_validation_error(cls, error: Mapping[str, Any]) -> "Error": + """Convert a single Pydantic error dict to a SCIM Error. + + If the error is a SCIM-specific error (raised via + :meth:`SCIMException.as_pydantic_error`), its scim_type and status + are preserved. Otherwise, a best-effort mapping is performed. + + :param error: A single error dict from ``ValidationError.errors()``. + :return: A SCIM Error object. + """ + if error["type"].startswith("scim_"): + ctx = error.get("ctx", {}) + return cls( + status=ctx.get("status", 400), + scim_type=ctx.get("scim_type"), + detail=error["msg"], + ) + + loc = ", ".join(str(loc) for loc in error["loc"]) + detail = f"{error['msg']}: {loc}" if loc else error["msg"] + + scim_type: str | None = None + error_type = error["type"] + if error_type in ("missing", "required_error"): + scim_type = "invalidValue" + elif error_type in ( + "string_type", + "int_type", + "int_parsing", + "bool_type", + "bool_parsing", + "float_type", + "float_parsing", + "json_invalid", + "value_error", + ): + scim_type = "invalidSyntax" + + return cls(status=400, scim_type=scim_type, detail=detail) + + @classmethod + def from_validation_errors( + cls, errors: ValidationError | Sequence[Mapping[str, Any]] + ) -> list["Error"]: + """Convert Pydantic validation errors to a list of SCIM Errors. + + :param errors: A ``ValidationError`` or a list of error dicts. + :return: A list of SCIM Error objects. + """ + error_list = errors.errors() if isinstance(errors, ValidationError) else errors + return [cls.from_validation_error(error) for error in error_list] diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index fe67c0f..63930cd 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -15,12 +15,12 @@ from ..annotations import Required from ..attributes import ComplexAttribute from ..context import Context +from ..exceptions import InvalidValueException +from ..exceptions import MutabilityException +from ..exceptions import NoTargetException from ..path import URN -from ..path import InvalidPathError from ..path import Path -from ..path import PathNotFoundError from ..resources.resource import Resource -from .error import Error from .message import Message from .message import _get_resource_class @@ -63,11 +63,15 @@ def _validate_mutability( PatchOperation.Op.add, PatchOperation.Op.replace_, ): - raise ValueError(Error.make_mutability_error().detail) + raise MutabilityException( + attribute=field_name, mutability="readOnly", operation=self.op.value + ).as_pydantic_error() # RFC 7643 Section 7: "Attributes with mutability 'immutable' SHALL NOT be updated" if mutability == Mutability.immutable and self.op == PatchOperation.Op.replace_: - raise ValueError(Error.make_mutability_error().detail) + raise MutabilityException( + attribute=field_name, mutability="immutable", operation=self.op.value + ).as_pydantic_error() def _validate_required_attribute( self, resource_class: type[Resource[Any]], field_name: str @@ -85,7 +89,9 @@ def _validate_required_attribute( # RFC 7643 Section 7: "Required attributes SHALL NOT be removed" if required == Required.true: - raise ValueError(Error.make_invalid_value_error().detail) + raise InvalidValueException( + detail="required attribute cannot be removed", attribute=field_name + ).as_pydantic_error() @model_validator(mode="after") def validate_operation_requirements(self, info: ValidationInfo) -> Self: @@ -98,11 +104,15 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: # RFC 7644 Section 3.5.2.2: "If 'path' is unspecified, the operation # fails with HTTP status code 400 and a 'scimType' error of 'noTarget'" if self.path is None and self.op == PatchOperation.Op.remove: - raise ValueError(Error.make_no_target_error().detail) + raise NoTargetException( + detail="Remove operation requires a path" + ).as_pydantic_error() # RFC 7644 Section 3.5.2.1: "Value is required for add operations" if self.op == PatchOperation.Op.add and self.value is None: - raise ValueError(Error.make_invalid_value_error().detail) + raise InvalidValueException( + detail="value is required for add operations" + ).as_pydantic_error() return self @@ -218,7 +228,9 @@ def validate_operations(self, info: ValidationInfo) -> Self: # RFC 7644: The body of an HTTP PATCH request MUST contain the attribute "Operations" scim_ctx = info.context.get("scim") if info.context else None if scim_ctx == Context.RESOURCE_PATCH_REQUEST and self.operations is None: - raise ValueError(Error.make_invalid_value_error().detail) + raise InvalidValueException( + detail="operations attribute is required" + ).as_pydantic_error() resource_class = _get_resource_class(self) if resource_class is None or not self.operations: @@ -248,7 +260,7 @@ def patch(self, resource: ResourceT) -> bool: :param resource: The SCIM resource to patch. This object is modified in-place. :type resource: T :return: True if the resource was modified by any operation, False otherwise. - :raises ValueError: If an operation is invalid (e.g., invalid path, forbidden mutation). + :raises SCIMException: If an operation is invalid (e.g., invalid path, forbidden mutation). """ if not self.operations: return False @@ -273,34 +285,24 @@ def _apply_operation( if operation.op == PatchOperation.Op.remove: return self._apply_remove(resource, operation) - raise ValueError(Error.make_invalid_value_error().detail) + raise InvalidValueException(detail=f"unsupported operation: {operation.op}") def _apply_add_replace( self, resource: Resource[Any], operation: PatchOperation[ResourceT] ) -> bool: """Apply an add or replace operation.""" path = operation.path if operation.path is not None else Path("") - try: - return path.set( - resource, # type: ignore[arg-type] - operation.value, - is_add=operation.op == PatchOperation.Op.add, - ) - except InvalidPathError as exc: - raise ValueError(Error.make_invalid_path_error().detail) from exc - except PathNotFoundError as exc: - raise ValueError(Error.make_invalid_path_error().detail) from exc + return path.set( + resource, # type: ignore[arg-type] + operation.value, + is_add=operation.op == PatchOperation.Op.add, + ) def _apply_remove( self, resource: Resource[Any], operation: PatchOperation[ResourceT] ) -> bool: """Apply a remove operation.""" if operation.path is None: - raise ValueError(Error.make_no_target_error().detail) - - try: - return operation.path.delete(resource, operation.value) # type: ignore[arg-type] - except InvalidPathError as exc: - raise ValueError(Error.make_invalid_path_error().detail) from exc - except PathNotFoundError as exc: - raise ValueError(Error.make_invalid_path_error().detail) from exc + raise NoTargetException(detail="Remove operation requires a path") + + return operation.path.delete(resource, operation.value) # type: ignore[arg-type] diff --git a/scim2_models/path.py b/scim2_models/path.py index 6198ae5..36c0b99 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -20,6 +20,9 @@ from .annotations import Required from .resources.resource import Resource +from .exceptions import InvalidPathException +from .exceptions import PathNotFoundException + ResourceT = TypeVar("ResourceT", bound="Resource[Any]") _VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$') @@ -42,24 +45,12 @@ def _value_in_list(current_list: list[Any], new_value: Any) -> bool: def _require_field(model: type[BaseModel], name: str) -> str: - """Find field name or raise PathNotFoundError.""" + """Find field name or raise PathNotFoundException.""" if (field_name := _find_field_name(model, name)) is None: - raise PathNotFoundError(f"Field not found: {name}") + raise PathNotFoundException(path=name, field=name) return field_name -class PathError(ValueError): - """Base exception for path operation failures.""" - - -class PathNotFoundError(PathError): - """Exception raised when a path references a non-existent field.""" - - -class InvalidPathError(PathError): - """Exception raised when a path is malformed or invalid.""" - - class _Resolution(NamedTuple): """Result of instance path resolution.""" @@ -405,7 +396,7 @@ def _resolve_instance( :param resource: The resource to resolve against. :param create: If True, create extension instance if it doesn't exist. :returns: Resolution with target object and path, or None if target doesn't exist. - :raises InvalidPathError: If the path references an unknown extension. + :raises InvalidPathException: If the path references an unknown extension. """ from .resources.resource import Extension from .resources.resource import Resource @@ -440,7 +431,7 @@ def _resolve_instance( return None return _Resolution(ext_obj, sub_path) - raise InvalidPathError(f"Extension not found for path: {self}") + raise InvalidPathException(path=str(self)) return None @@ -486,12 +477,12 @@ def get(self, resource: ResourceT, *, strict: bool = True) -> Any: :param resource: The resource to get the value from. :param strict: If True, raise exceptions for invalid paths. :returns: The value at this path, or None if the value is absent. - :raises PathNotFoundError: If strict and the path references a non-existent field. - :raises InvalidPathError: If strict and the path references an unknown extension. + :raises PathNotFoundException: If strict and the path references a non-existent field. + :raises InvalidPathException: If strict and the path references an unknown extension. """ try: return self._get(resource) - except PathError: + except InvalidPathException: if strict: raise return None @@ -508,7 +499,7 @@ def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool if not path_str: if not isinstance(value, dict): if is_explicit_schema_path: - raise InvalidPathError(f"Schema path requires dict value: {self}") + raise InvalidPathException(path=str(self)) return False filtered_value = { k: v @@ -563,11 +554,11 @@ def set( list instead of replacing. Duplicates are not added. :param strict: If True, raise exceptions for invalid paths. :returns: True if the value was set/added, False if unchanged. - :raises PathError: If strict and the path does not exist or is invalid. + :raises InvalidPathException: If strict and the path does not exist or is invalid. """ try: return self._set(resource, value, is_add=is_add) - except PathError: + except InvalidPathException: if strict: raise return False @@ -609,7 +600,7 @@ def _delete(self, resource: ResourceT, value: Any | None = None) -> bool: return False if not resolution.path_str: - raise InvalidPathError(f"Cannot delete schema-only path: {self}") + raise InvalidPathException(path=str(self)) if ( result := self._walk_to_target(resolution.target, resolution.path_str) @@ -647,11 +638,11 @@ def delete( :param value: Optional specific value to remove from a list. :param strict: If True, raise exceptions for invalid paths. :returns: True if a value was deleted, False if unchanged. - :raises PathError: If strict and the path does not exist or is invalid. + :raises InvalidPathException: If strict and the path does not exist or is invalid. """ try: return self._delete(resource, value) - except PathError: + except InvalidPathException: if strict: raise return False diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index e2ec152..479016a 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -28,8 +28,8 @@ from ..attributes import is_complex_attribute from ..base import BaseModel from ..context import Context +from ..exceptions import InvalidPathException from ..path import Path -from ..path import PathError from ..reference import Reference from ..scim_object import ScimObject from ..utils import UNION_TYPES @@ -231,7 +231,7 @@ def __getitem__(self, item: Any) -> Any: path = item if isinstance(item, Path) else bound_path(str(item)) try: return path.get(self) - except PathError as exc: + except InvalidPathException as exc: raise KeyError(str(item)) from exc def __setitem__(self, item: Any, value: Any) -> None: @@ -254,7 +254,7 @@ def __setitem__(self, item: Any, value: Any) -> None: path = item if isinstance(item, Path) else bound_path(str(item)) try: path.set(self, value) - except PathError as exc: + except InvalidPathException as exc: raise KeyError(str(item)) from exc def __delitem__(self, item: Any) -> None: @@ -275,7 +275,7 @@ def __delitem__(self, item: Any) -> None: path = item if isinstance(item, Path) else bound_path(str(item)) try: path.delete(self) - except PathError as exc: + except InvalidPathException as exc: raise KeyError(str(item)) from exc @classmethod diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..5c5080a --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,321 @@ +"""Tests for SCIM exceptions.""" + +import pytest +from pydantic import BaseModel +from pydantic import HttpUrl +from pydantic import ValidationError +from pydantic import field_validator + +from scim2_models import Error +from scim2_models import InvalidFilterException +from scim2_models import InvalidPathException +from scim2_models import InvalidSyntaxException +from scim2_models import InvalidValueException +from scim2_models import InvalidVersionException +from scim2_models import MutabilityException +from scim2_models import NoTargetException +from scim2_models import PathNotFoundException +from scim2_models import SCIMException +from scim2_models import SensitiveException +from scim2_models import TooManyException +from scim2_models import UniquenessException + + +def test_base_exception_default_message(): + """SCIMException uses default message when no detail is provided.""" + exc = SCIMException() + assert str(exc) == "A SCIM error occurred" + assert exc.status == 400 + assert exc.scim_type == "" + + +def test_base_exception_custom_message(): + """SCIMException uses custom detail when provided.""" + exc = SCIMException(detail="Custom error message") + assert str(exc) == "Custom error message" + assert exc.detail == "Custom error message" + + +def test_to_error(): + """to_error() converts SCIMException to Error response object.""" + exc = SCIMException(detail="Test error") + error = exc.to_error() + assert isinstance(error, Error) + assert error.status == 400 + assert error.scim_type is None + assert error.detail == "Test error" + + +def test_as_pydantic_error(): + """as_pydantic_error() converts to PydanticCustomError.""" + exc = InvalidPathException(detail="Bad path", path="/invalid") + pydantic_error = exc.as_pydantic_error() + assert pydantic_error.type == "scim_invalidPath" + assert "Bad path" in str(pydantic_error) + + +def test_context_attributes(): + """Extra keyword arguments are stored in context dict.""" + exc = InvalidPathException(detail="Error", path="/test", extra="data") + assert exc.path == "/test" + assert exc.context["extra"] == "data" + + +def test_invalid_filter_exception(): + """InvalidFilterException has correct status and scim_type.""" + exc = InvalidFilterException(filter="invalid()") + assert exc.status == 400 + assert exc.scim_type == "invalidFilter" + assert exc.filter == "invalid()" + error = exc.to_error() + assert error.scim_type == "invalidFilter" + + +def test_too_many_exception(): + """TooManyException has correct status and scim_type.""" + exc = TooManyException() + assert exc.status == 400 + assert exc.scim_type == "tooMany" + + +def test_uniqueness_exception(): + """UniquenessException has status 409 and stores attribute/value.""" + exc = UniquenessException(attribute="userName", value="john") + assert exc.status == 409 + assert exc.scim_type == "uniqueness" + assert exc.attribute == "userName" + assert exc.value == "john" + + +def test_mutability_exception(): + """MutabilityException stores attribute, mutability and operation.""" + exc = MutabilityException( + attribute="id", mutability="readOnly", operation="replace" + ) + assert exc.status == 400 + assert exc.scim_type == "mutability" + assert exc.attribute == "id" + assert exc.mutability == "readOnly" + assert exc.operation == "replace" + + +def test_invalid_syntax_exception(): + """InvalidSyntaxException has correct status and scim_type.""" + exc = InvalidSyntaxException() + assert exc.status == 400 + assert exc.scim_type == "invalidSyntax" + + +def test_invalid_path_exception(): + """InvalidPathException stores the invalid path.""" + exc = InvalidPathException(path="invalid..path") + assert exc.status == 400 + assert exc.scim_type == "invalidPath" + assert exc.path == "invalid..path" + + +def test_path_not_found_exception(): + """PathNotFoundException includes field name in message.""" + exc = PathNotFoundException(path="unknownField", field="unknownField") + assert exc.status == 400 + assert exc.scim_type == "invalidPath" + assert exc.path == "unknownField" + assert exc.field == "unknownField" + assert isinstance(exc, InvalidPathException) + assert str(exc) == "Field not found: unknownField" + + +def test_path_not_found_exception_with_custom_detail(): + """PathNotFoundException uses custom detail when provided.""" + exc = PathNotFoundException(field="foo", detail="Custom message") + assert str(exc) == "Custom message" + + +def test_path_not_found_exception_without_field(): + """PathNotFoundException uses default message when no field is provided.""" + exc = PathNotFoundException() + assert str(exc) == "The specified path references a non-existent field" + + +def test_no_target_exception(): + """NoTargetException stores the path that yielded no target.""" + exc = NoTargetException(path="emails[type eq 'work']") + assert exc.status == 400 + assert exc.scim_type == "noTarget" + assert exc.path == "emails[type eq 'work']" + + +def test_invalid_value_exception(): + """InvalidValueException stores attribute and reason.""" + exc = InvalidValueException(attribute="active", reason="must be boolean") + assert exc.status == 400 + assert exc.scim_type == "invalidValue" + assert exc.attribute == "active" + assert exc.reason == "must be boolean" + + +def test_invalid_version_exception(): + """InvalidVersionException has scim_type 'invalidVers'.""" + exc = InvalidVersionException() + assert exc.status == 400 + assert exc.scim_type == "invalidVers" + + +def test_sensitive_exception(): + """SensitiveException has correct status and scim_type.""" + exc = SensitiveException() + assert exc.status == 400 + assert exc.scim_type == "sensitive" + + +def test_exception_in_pydantic_validator(): + """SCIM exceptions can be raised in Pydantic validators via as_pydantic_error().""" + + class TestModel(BaseModel): + value: str + + @field_validator("value") + @classmethod + def validate_value(cls, v: str) -> str: + if v == "invalid": + raise InvalidValueException( + detail="Value cannot be 'invalid'" + ).as_pydantic_error() + return v + + with pytest.raises(ValidationError) as exc_info: + TestModel(value="invalid") + + errors = exc_info.value.errors() + assert len(errors) == 1 + assert errors[0]["type"] == "scim_invalidValue" + assert "Value cannot be 'invalid'" in errors[0]["msg"] + + assert TestModel(value="valid").value == "valid" + + +def test_from_validation_error_with_scim_error(): + """from_validation_error() preserves scim_type from SCIM exceptions.""" + + class TestModel(BaseModel): + value: str + + @field_validator("value") + @classmethod + def validate_value(cls, v: str) -> str: + if v == "bad": + raise NoTargetException(detail="No target found").as_pydantic_error() + return v + + with pytest.raises(ValidationError) as exc_info: + TestModel(value="bad") + + error = Error.from_validation_error(exc_info.value.errors()[0]) + assert error.status == 400 + assert error.scim_type == "noTarget" + assert error.detail == "No target found" + + assert TestModel(value="good").value == "good" + + +def test_from_validation_error_with_standard_pydantic_error(): + """from_validation_error() maps Pydantic type errors to invalidSyntax.""" + + class TestModel(BaseModel): + value: int + + with pytest.raises(ValidationError) as exc_info: + TestModel(value="not_an_int") + + error = Error.from_validation_error(exc_info.value.errors()[0]) + assert error.status == 400 + assert error.scim_type == "invalidSyntax" + assert "value" in error.detail + + +def test_from_validation_error_with_missing_field(): + """from_validation_error() maps missing required fields to invalidValue.""" + + class TestModel(BaseModel): + required_field: str + + with pytest.raises(ValidationError) as exc_info: + TestModel() + + error = Error.from_validation_error(exc_info.value.errors()[0]) + assert error.status == 400 + assert error.scim_type == "invalidValue" + assert "required_field" in error.detail + + +def test_from_validation_error_with_unmapped_error_type(): + """from_validation_error() returns scim_type=None for unmapped error types.""" + + class TestModel(BaseModel): + url: HttpUrl + + with pytest.raises(ValidationError) as exc_info: + TestModel(url="not a url") + + error = Error.from_validation_error(exc_info.value.errors()[0]) + assert error.status == 400 + assert error.scim_type is None + assert "url" in error.detail + + +def test_from_validation_errors_with_validation_error(): + """from_validation_errors() accepts a ValidationError directly.""" + + class TestModel(BaseModel): + a: int + b: int + + with pytest.raises(ValidationError) as exc_info: + TestModel(a="x", b="y") + + errors = Error.from_validation_errors(exc_info.value) + assert len(errors) == 2 + assert all(e.scim_type == "invalidSyntax" for e in errors) + + +def test_from_validation_errors_with_list(): + """from_validation_errors() accepts a list of error dicts.""" + + class TestModel(BaseModel): + a: int + b: int + + with pytest.raises(ValidationError) as exc_info: + TestModel(a="x", b="y") + + errors = Error.from_validation_errors(exc_info.value.errors()) + assert len(errors) == 2 + assert all(isinstance(e, Error) for e in errors) + + +def test_all_exceptions_inherit_from_scim_exception(): + """All SCIM exceptions inherit from SCIMException, not ValueError.""" + exceptions = [ + InvalidFilterException(), + TooManyException(), + UniquenessException(), + MutabilityException(), + InvalidSyntaxException(), + InvalidPathException(), + PathNotFoundException(), + NoTargetException(), + InvalidValueException(), + InvalidVersionException(), + SensitiveException(), + ] + for exc in exceptions: + assert isinstance(exc, SCIMException) + assert isinstance(exc, Exception) + assert not isinstance(exc, ValueError) + + +def test_path_not_found_is_invalid_path(): + """PathNotFoundException is a subclass of InvalidPathException.""" + exc = PathNotFoundException() + assert isinstance(exc, InvalidPathException) + assert exc.scim_type == "invalidPath" diff --git a/tests/test_patch_op_extensions.py b/tests/test_patch_op_extensions.py index 41afd71..ece542c 100644 --- a/tests/test_patch_op_extensions.py +++ b/tests/test_patch_op_extensions.py @@ -6,6 +6,7 @@ from scim2_models import Group from scim2_models import GroupMember +from scim2_models import InvalidPathException from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import User @@ -169,7 +170,7 @@ def test_patch_operation_extension_invalid_path_error(): ) ] ) - with pytest.raises(ValueError, match="path.*invalid"): + with pytest.raises(InvalidPathException): patch1.patch(user) patch2 = PatchOp[User]( @@ -181,7 +182,7 @@ def test_patch_operation_extension_invalid_path_error(): ) ] ) - with pytest.raises(ValueError, match="path.*invalid"): + with pytest.raises(InvalidPathException): patch2.patch(user) @@ -381,7 +382,7 @@ def test_patch_schema_path_with_invalid_value_type(): ] ) - with pytest.raises(ValueError, match="path.*invalid.*malformed"): + with pytest.raises(InvalidPathException): patch.patch(user) diff --git a/tests/test_patch_op_remove.py b/tests/test_patch_op_remove.py index e932e74..d1025ae 100644 --- a/tests/test_patch_op_remove.py +++ b/tests/test_patch_op_remove.py @@ -3,6 +3,7 @@ from scim2_models import Group from scim2_models import GroupMember +from scim2_models import NoTargetException from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import User @@ -228,7 +229,7 @@ def test_complex_object_creation_and_basemodel_matching(): def test_remove_operation_bypass_validation_no_path(): """Test remove operation with no path raises noTarget error per RFC7644 §3.5.2.2.""" - with pytest.raises(ValidationError, match="no match"): + with pytest.raises(ValidationError, match="Remove operation requires a path"): PatchOp.model_validate( { "operations": [ @@ -251,5 +252,5 @@ def test_defensive_path_check_in_remove(): op=PatchOperation.Op.remove, path=None ) - with pytest.raises(ValueError, match="no match"): + with pytest.raises(NoTargetException, match="Remove operation requires a path"): patch.patch(user) diff --git a/tests/test_patch_op_validation.py b/tests/test_patch_op_validation.py index 7f59994..d79dcc8 100644 --- a/tests/test_patch_op_validation.py +++ b/tests/test_patch_op_validation.py @@ -4,6 +4,8 @@ from pydantic import ValidationError from scim2_models import Group +from scim2_models import InvalidPathException +from scim2_models import InvalidValueException from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import User @@ -22,10 +24,7 @@ def test_patch_op_add_invalid_extension_path(): ) ] ) - with pytest.raises( - ValueError, - match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', - ): + with pytest.raises(InvalidPathException): patch_op.patch(user) @@ -40,10 +39,7 @@ def test_patch_op_replace_invalid_extension_path(): ) ] ) - with pytest.raises( - ValueError, - match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', - ): + with pytest.raises(InvalidPathException): patch_op.patch(user) @@ -57,10 +53,7 @@ def test_patch_op_remove_invalid_extension_path(): ) ] ) - with pytest.raises( - ValueError, - match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', - ): + with pytest.raises(InvalidPathException): patch_op.patch(user) @@ -75,10 +68,7 @@ def test_patch_op_remove_invalid_extension_path_with_value(): ) ] ) - with pytest.raises( - ValueError, - match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', - ): + with pytest.raises(InvalidPathException): patch_op.patch(user) @@ -176,7 +166,7 @@ def test_path_required_for_remove_operations(): ) # RFC 7644 §3.5.2.2: remove without path returns noTarget error - with pytest.raises(ValidationError, match="no match"): + with pytest.raises(ValidationError, match="Remove operation requires a path"): PatchOp[User].model_validate( { "operations": [ @@ -234,13 +224,13 @@ def test_patch_operation_validation_contexts(): ) # RFC 7644 §3.5.2.2: remove without path returns noTarget error - with pytest.raises(ValidationError, match="no match"): + with pytest.raises(ValidationError, match="Remove operation requires a path"): PatchOperation.model_validate( {"op": "remove"}, context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) - with pytest.raises(ValidationError, match="required value was missing"): + with pytest.raises(ValidationError, match="value is required for add operations"): PatchOperation.model_validate( {"op": "add", "path": "test"}, context={"scim": Context.RESOURCE_PATCH_REQUEST}, @@ -377,7 +367,7 @@ def test_patch_operation_with_schema_only_urn_path(): ) # This should trigger the path where extract_field_name returns None - with pytest.raises(ValueError, match="path"): + with pytest.raises(InvalidPathException): patch.patch(user) @@ -481,14 +471,14 @@ def test_create_parent_object_return_none(): ) # Non-existent field returns invalidPath error - with pytest.raises(ValueError, match="path.*invalid"): + with pytest.raises(InvalidPathException): patch.patch(user) def test_validate_required_field_removal(): """Test that removing required fields raises validation error.""" # Test removing schemas (required field) should raise validation error - with pytest.raises(ValidationError, match="required value was missing"): + with pytest.raises(ValidationError, match="required attribute cannot be removed"): PatchOp[User].model_validate( {"operations": [{"op": "remove", "path": "schemas"}]}, context={"scim": Context.RESOURCE_PATCH_REQUEST}, @@ -509,7 +499,7 @@ def test_patch_error_handling_invalid_operation(): # Force invalid operation type to test error handling object.__setattr__(patch.operations[0], "op", "invalid_operation") - with pytest.raises(ValueError, match="invalid value|required value was missing"): + with pytest.raises(InvalidValueException): patch.patch(user) @@ -527,7 +517,7 @@ def test_remove_value_at_path_invalid_field(): ) # Non-existent field returns invalidPath error - with pytest.raises(ValueError, match="path.*invalid"): + with pytest.raises(InvalidPathException): patch.patch(user) @@ -547,14 +537,14 @@ def test_remove_specific_value_invalid_field(): ) # Non-existent field returns invalidPath error - with pytest.raises(ValueError, match="path.*invalid"): + with pytest.raises(InvalidPathException): patch.patch(user) def test_patch_op_operations_attribute_required_in_patch_context(): """Test that Operations attribute is required in PATCH request context per RFC 7644.""" # Operations attribute must be present in PATCH request context - with pytest.raises(ValidationError, match="required value was missing"): + with pytest.raises(ValidationError, match="operations attribute is required"): PatchOp[User].model_validate( {"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"]}, context={"scim": Context.RESOURCE_PATCH_REQUEST}, diff --git a/tests/test_path.py b/tests/test_path.py index d2e433e..195ee76 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -8,15 +8,15 @@ from scim2_models import Email from scim2_models import EnterpriseUser from scim2_models import Group +from scim2_models import InvalidPathException from scim2_models import Manager from scim2_models import Mutability from scim2_models import Name +from scim2_models import PathNotFoundException from scim2_models import Required from scim2_models import User from scim2_models.base import BaseModel -from scim2_models.path import InvalidPathError from scim2_models.path import Path -from scim2_models.path import PathNotFoundError def test_validate_scim_path_syntax_valid_paths(): @@ -300,10 +300,10 @@ def test_get_none_when_parent_missing(): def test_get_invalid_attribute(): - """Get raises PathNotFoundError for invalid attribute.""" + """Get raises PathNotFoundException for invalid attribute.""" user = User(user_name="john") path = Path("invalidAttribute") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.get(user) @@ -320,10 +320,10 @@ def test_get_extension_complex_subattribute(): def test_get_invalid_first_part_in_complex_path(): - """Get raises PathNotFoundError when first part of complex path is invalid.""" + """Get raises PathNotFoundException when first part of complex path is invalid.""" user = User(user_name="john", name=Name(family_name="Doe")) path = Path("invalidAttr.subField") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.get(user) @@ -416,10 +416,10 @@ def test_set_multivalued_wraps_single_value(): def test_set_invalid_attribute(): - """Set raises PathNotFoundError for invalid attribute.""" + """Set raises PathNotFoundException for invalid attribute.""" user = User(user_name="john") path = Path("invalidAttribute") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.set(user, "value") @@ -435,10 +435,10 @@ def test_set_extension_complex_subattribute(): def test_set_invalid_first_part_in_complex_path(): - """Set raises PathNotFoundError when first part of complex path is invalid.""" + """Set raises PathNotFoundException when first part of complex path is invalid.""" user = User(user_name="john") path = Path("invalidAttr.subField") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.set(user, "value") @@ -479,10 +479,10 @@ def test_set_cannot_navigate_into_list(): def test_set_invalid_last_part_in_complex_path(): - """Set raises PathNotFoundError when last part of complex path is invalid.""" + """Set raises PathNotFoundException when last part of complex path is invalid.""" user = User(user_name="john", name=Name(family_name="Doe")) path = Path("name.invalidField") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.set(user, "value") @@ -738,10 +738,10 @@ def test_delete_complex_subattribute(): def test_delete_invalid_path(): - """Delete raises PathNotFoundError for invalid path.""" + """Delete raises PathNotFoundException for invalid path.""" user = User(user_name="john") path = Path("invalidAttribute") - with pytest.raises(PathNotFoundError): + with pytest.raises(PathNotFoundException): path.delete(user) @@ -1005,10 +1005,10 @@ def test_get_extension_attribute_when_extension_is_none(): def test_get_unknown_extension_raises(): - """Get raises InvalidPathError for unknown extension URN.""" + """Get raises InvalidPathException for unknown extension URN.""" user = User[EnterpriseUser](user_name="john") path = Path("urn:ietf:params:scim:schemas:extension:unknown:2.0:User:field") - with pytest.raises(InvalidPathError): + with pytest.raises(InvalidPathException): path.get(user) @@ -1039,10 +1039,10 @@ def test_get_mismatched_urn_on_extension_instance(): def test_set_explicit_schema_path_with_non_dict_raises(): - """Set with explicit schema path and non-dict value raises InvalidPathError.""" + """Set with explicit schema path and non-dict value raises InvalidPathException.""" user = User(user_name="john") path = Path("urn:ietf:params:scim:schemas:core:2.0:User") - with pytest.raises(InvalidPathError): + with pytest.raises(InvalidPathException): path.set(user, "not a dict") @@ -1171,10 +1171,10 @@ def test_delete_on_extension_with_mismatched_urn(): def test_delete_schema_only_path_raises(): - """Delete with schema-only path raises InvalidPathError.""" + """Delete with schema-only path raises InvalidPathException.""" user = User(user_name="john") path = Path("urn:ietf:params:scim:schemas:core:2.0:User") - with pytest.raises(InvalidPathError): + with pytest.raises(InvalidPathException): path.delete(user)