From 88533ca4b1820f7bcf43f4dae4b7cc4082b1c772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Wed, 20 Aug 2025 00:11:13 +0200 Subject: [PATCH] refactor: introduce Path and URN types --- doc/changelog.rst | 7 + doc/tutorial.rst | 24 +- pyproject.toml | 2 +- scim2_models/__init__.py | 10 + scim2_models/messages/patch_op.py | 297 +---- scim2_models/messages/search_request.py | 55 +- scim2_models/path.py | 738 ++++++++++++ scim2_models/resources/resource.py | 86 +- scim2_models/resources/schema.py | 2 +- scim2_models/urn.py | 126 -- scim2_models/utils.py | 83 -- tests/test_model_attributes.py | 46 - tests/test_model_serialization.py | 46 +- tests/test_patch_op_add.py | 38 +- tests/test_patch_op_extensions.py | 92 +- tests/test_patch_op_remove.py | 76 +- tests/test_patch_op_replace.py | 26 +- tests/test_patch_op_validation.py | 143 ++- tests/test_path.py | 1419 +++++++++++++++++++++++ tests/test_path_validation.py | 112 -- tests/test_resource_extension.py | 67 +- tests/test_search_request.py | 6 +- tests/test_urn.py | 36 + uv.lock | 4 +- 24 files changed, 2623 insertions(+), 918 deletions(-) create mode 100644 scim2_models/path.py delete mode 100644 scim2_models/urn.py create mode 100644 tests/test_path.py delete mode 100644 tests/test_path_validation.py create mode 100644 tests/test_urn.py diff --git a/doc/changelog.rst b/doc/changelog.rst index 515a6d2..a3981e6 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,13 @@ Changelog ========= +[0.x.x] - Unreleased +-------------------- + +Changed +^^^^^ +- Introduce a Path object to handle paths. :issue:`111` + [0.5.2] - 2026-01-22 -------------------- diff --git a/doc/tutorial.rst b/doc/tutorial.rst index bde98ff..659a496 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -1,11 +1,33 @@ Tutorial -------- +Attribute access +================ + +SCIM resources support two ways to access and modify attributes. +The standard Python dot notation uses snake_case attribute names, while the bracket notation accepts SCIM paths as defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`. + +.. doctest:: + + >>> from scim2_models import User + + >>> user = User(user_name="bjensen") + >>> user.display_name = "Barbara Jensen" + >>> user["nickName"] = "Babs" + >>> user["name.familyName"] = "Jensen" + +Attributes can be removed with ``del``. + +.. doctest:: + + >>> del user["nickName"] + >>> user.nick_name is None + True + Model parsing ============= Pydantic :func:`~scim2_models.BaseModel.model_validate` method can be used to parse and validate SCIM2 payloads. -Python models have generally the same name than in the SCIM specifications, they are simply snake cased. .. code-block:: python diff --git a/pyproject.toml b/pyproject.toml index 2fdd5b7..29ba59f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ requires-python = ">= 3.10" dependencies = [ - "pydantic[email]>=2.7.0" + "pydantic[email]>=2.12.0" ] [project.urls] diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index dd947f7..3a2d916 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -16,6 +16,11 @@ from .messages.patch_op import PatchOp 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 @@ -79,6 +84,7 @@ "GroupMember", "GroupMembership", "Im", + "InvalidPathError", "ListResponse", "Manager", "Message", @@ -86,6 +92,9 @@ "Mutability", "MultiValuedComplexAttribute", "Name", + "Path", + "PathError", + "PathNotFoundError", "Patch", "PatchOp", "PatchOperation", @@ -104,6 +113,7 @@ "Sort", "Uniqueness", "URIReference", + "URN", "User", "X509Certificate", ] diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index b5a7876..8c7aea0 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -14,22 +14,19 @@ from ..annotations import Mutability from ..annotations import Required from ..attributes import ComplexAttribute -from ..base import BaseModel from ..context import Context +from ..path import InvalidPathError +from ..path import Path +from ..path import PathNotFoundError from ..resources.resource import Resource -from ..urn import _resolve_path_to_target -from ..utils import _extract_field_name -from ..utils import _find_field_name -from ..utils import _get_path_parts -from ..utils import _validate_scim_path_syntax from .error import Error from .message import Message from .message import _get_resource_class -T = TypeVar("T", bound=Resource[Any]) +ResourceT = TypeVar("ResourceT", bound=Resource[Any]) -class PatchOperation(ComplexAttribute): +class PatchOperation(ComplexAttribute, Generic[ResourceT]): class Op(str, Enum): replace_ = "replace" remove = "remove" @@ -46,7 +43,7 @@ class Op(str, Enum): despite :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, op is case-insensitive. """ - path: str | None = None + path: Path[ResourceT] | None = None """The "path" attribute value is a String containing an attribute path describing the target of the operation.""" @@ -97,13 +94,10 @@ def validate_operation_requirements(self, info: ValidationInfo) -> Self: if scim_ctx != Context.RESOURCE_PATCH_REQUEST: return self - # RFC 7644 Section 3.5.2: "Path syntax validation according to ABNF grammar" - if self.path is not None and not _validate_scim_path_syntax(self.path): - raise ValueError(Error.make_invalid_path_error().detail) - - # RFC 7644 Section 3.5.2.3: "Path is required for remove operations" + # 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_invalid_path_error().detail) + raise ValueError(Error.make_no_target_error().detail) # RFC 7644 Section 3.5.2.1: "Value is required for add operations" if self.op == PatchOperation.Op.add and self.value is None: @@ -130,10 +124,10 @@ def normalize_op(cls, v: Any) -> Any: return v -class PatchOp(Message, Generic[T]): +class PatchOp(Message, Generic[ResourceT]): """Patch Operation as defined in :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. - Type parameter T is required and must be a concrete Resource subclass. + Type parameter ResourceT is required and must be a concrete Resource subclass. Usage: PatchOp[User], PatchOp[Group], etc. .. note:: @@ -209,8 +203,8 @@ def __class_getitem__( "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] - operations: Annotated[list[PatchOperation] | None, Required.true] = Field( - None, serialization_alias="Operations", min_length=1 + operations: Annotated[list[PatchOperation[ResourceT]] | None, Required.true] = ( + Field(None, serialization_alias="Operations", min_length=1) ) """The body of an HTTP PATCH request MUST contain the attribute "Operations", whose value is an array of one or more PATCH operations.""" @@ -236,13 +230,13 @@ def validate_operations(self, info: ValidationInfo) -> Self: if operation.path is None: continue - field_name = _extract_field_name(operation.path) + field_name = operation.path.parts[0] if operation.path.parts else None operation._validate_mutability(resource_class, field_name) # type: ignore[arg-type] operation._validate_required_attribute(resource_class, field_name) # type: ignore[arg-type] return self - def patch(self, resource: T) -> bool: + def patch(self, resource: ResourceT) -> bool: """Apply all PATCH operations to the given SCIM resource in sequence. The resource is modified in-place. @@ -269,7 +263,7 @@ def patch(self, resource: T) -> bool: return modified def _apply_operation( - self, resource: Resource[Any], operation: PatchOperation + self, resource: Resource[Any], operation: PatchOperation[ResourceT] ) -> bool: """Apply a single patch operation to a resource. @@ -283,254 +277,31 @@ def _apply_operation( raise ValueError(Error.make_invalid_value_error().detail) def _apply_add_replace( - self, resource: Resource[Any], operation: PatchOperation + self, resource: Resource[Any], operation: PatchOperation[ResourceT] ) -> bool: """Apply an add or replace operation.""" - # RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path" - if operation.path is not None: - return self._set_value_at_path( - resource, - operation.path, + 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 - # RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level" - return self._apply_root_attributes(resource, operation.value) - - def _apply_remove(self, resource: Resource[Any], operation: PatchOperation) -> bool: + def _apply_remove( + self, resource: Resource[Any], operation: PatchOperation[ResourceT] + ) -> bool: """Apply a remove operation.""" - # RFC 7644 Section 3.5.2.3: "Path is required for remove operations" if operation.path is None: - raise ValueError(Error.make_invalid_path_error().detail) - - # RFC 7644 Section 3.5.2.3: "If a value is specified, remove only that value" - if operation.value is not None: - return self._remove_specific_value( - resource, operation.path, operation.value - ) - - return self._remove_value_at_path(resource, operation.path) - - @classmethod - def _apply_root_attributes(cls, resource: BaseModel, value: Any) -> bool: - """Apply attributes to the resource root.""" - if not isinstance(value, dict): - return False - - modified = False - for attr_name, val in value.items(): - field_name = _find_field_name(type(resource), attr_name) - if not field_name: - continue - - old_value = getattr(resource, field_name) - if old_value != val: - setattr(resource, field_name, val) - modified = True - - return modified - - @classmethod - def _set_value_at_path( - cls, resource: Resource[Any], path: str, value: Any, is_add: bool - ) -> bool: - """Set a value at a specific path.""" - target, attr_path = _resolve_path_to_target(resource, path) - - if not target: - raise ValueError(Error.make_invalid_path_error().detail) - - if not attr_path: - if not isinstance(value, dict): - raise ValueError(Error.make_invalid_path_error().detail) - - updated_data = {**target.model_dump(), **value} - updated_target = type(target).model_validate(updated_data) - target.__dict__.update(updated_target.__dict__) - return True - - path_parts = _get_path_parts(attr_path) - if len(path_parts) == 1: - return cls._set_simple_attribute(target, path_parts[0], value, is_add) - - return cls._set_complex_attribute(target, path_parts, value, is_add) - - @classmethod - def _set_simple_attribute( - cls, resource: BaseModel, attr_name: str, value: Any, is_add: bool - ) -> bool: - """Set a value on a simple (non-nested) attribute.""" - field_name = _find_field_name(type(resource), attr_name) - if not field_name: - raise ValueError(Error.make_no_target_error().detail) - - # RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values" - if is_add and cls._is_multivalued_field(resource, field_name): - return cls._handle_multivalued_add(resource, field_name, value) - - old_value = getattr(resource, field_name) - if old_value == value: - return False - - setattr(resource, field_name, value) - return True - - @classmethod - def _set_complex_attribute( - cls, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool - ) -> bool: - """Set a value on a complex (nested) attribute.""" - parent_attr = path_parts[0] - sub_path = ".".join(path_parts[1:]) - - parent_field_name = _find_field_name(type(resource), parent_attr) - if not parent_field_name: raise ValueError(Error.make_no_target_error().detail) - parent_obj = getattr(resource, parent_field_name) - if parent_obj is None: - parent_obj = cls._create_parent_object(resource, parent_field_name) - if parent_obj is None: - return False - - return cls._set_value_at_path(parent_obj, sub_path, value, is_add) - - @classmethod - def _is_multivalued_field(cls, resource: BaseModel, field_name: str) -> bool: - """Check if a field is multi-valued.""" - return hasattr(resource, field_name) and type(resource).get_field_multiplicity( - field_name - ) - - @classmethod - def _handle_multivalued_add( - cls, resource: BaseModel, field_name: str, value: Any - ) -> bool: - """Handle adding values to a multi-valued attribute.""" - current_list = getattr(resource, field_name) or [] - - # RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes" - if isinstance(value, list): - return cls._add_multiple_values(resource, field_name, current_list, value) - - return cls._add_single_value(resource, field_name, current_list, value) - - @classmethod - def _add_multiple_values( - cls, - resource: BaseModel, - field_name: str, - current_list: list[Any], - values: list[Any], - ) -> bool: - """Add multiple values to a multi-valued attribute.""" - new_values = [] - # RFC 7644 Section 3.5.2.1: "Do not add duplicate values" - for new_val in values: - if not cls._value_exists_in_list(current_list, new_val): - new_values.append(new_val) - - if not new_values: - return False - - setattr(resource, field_name, current_list + new_values) - return True - - @classmethod - def _add_single_value( - cls, resource: BaseModel, field_name: str, current_list: list[Any], value: Any - ) -> bool: - """Add a single value to a multi-valued attribute.""" - # RFC 7644 Section 3.5.2.1: "Do not add duplicate values" - if cls._value_exists_in_list(current_list, value): - return False - - current_list.append(value) - setattr(resource, field_name, current_list) - return True - - @classmethod - def _value_exists_in_list(cls, current_list: list[Any], new_value: Any) -> bool: - """Check if a value already exists in a list.""" - return any(cls._values_match(item, new_value) for item in current_list) - - @classmethod - def _create_parent_object(cls, resource: BaseModel, parent_field_name: str) -> Any: - """Create a parent object if it doesn't exist.""" - parent_class = type(resource).get_field_root_type(parent_field_name) - if not parent_class or not isclass(parent_class): - return None - - parent_obj = parent_class() - setattr(resource, parent_field_name, parent_obj) - return parent_obj - - @classmethod - def _remove_value_at_path(cls, resource: Resource[Any], path: str) -> bool: - """Remove a value at a specific path.""" - target, attr_path = _resolve_path_to_target(resource, path) - - # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute" - if not attr_path or not target: - raise ValueError(Error.make_invalid_path_error().detail) - - parent_attr, *path_parts = _get_path_parts(attr_path) - field_name = _find_field_name(type(target), parent_attr) - if not field_name: - raise ValueError(Error.make_no_target_error().detail) - parent_obj = getattr(target, field_name) - - if parent_obj is None: - return False - - # RFC 7644 Section 3.5.2.3: "Remove entire attribute if no sub-path" - if not path_parts: - setattr(target, field_name, None) - return True - - sub_path = ".".join(path_parts) - return cls._remove_value_at_path(parent_obj, sub_path) - - @classmethod - def _remove_specific_value( - cls, resource: Resource[Any], path: str, value_to_remove: Any - ) -> bool: - """Remove a specific value from a multi-valued attribute.""" - target, attr_path = _resolve_path_to_target(resource, path) - - # RFC 7644 Section 3.5.2.3: "Path must resolve to a valid attribute" - if not attr_path or not target: - raise ValueError(Error.make_invalid_path_error().detail) - - field_name = _find_field_name(type(target), attr_path) - if not field_name: - raise ValueError(Error.make_no_target_error().detail) - - current_list = getattr(target, field_name) - if not isinstance(current_list, list): - return False - - new_list = [] - modified = False - # RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes" - for item in current_list: - if not cls._values_match(item, value_to_remove): - new_list.append(item) - else: - modified = True - - if modified: - setattr(target, field_name, new_list if new_list else None) - return True - - return False - - @classmethod - def _values_match(cls, value1: Any, value2: Any) -> bool: - """Check if two values match, converting BaseModel to dict for comparison.""" - - def to_dict(value: Any) -> dict[str, Any]: - return value.model_dump() if isinstance(value, BaseModel) else value - - return to_dict(value1) == to_dict(value2) + 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 diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index ccadaaf..1e5707c 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -1,12 +1,12 @@ from enum import Enum from typing import Annotated +from typing import Any from pydantic import field_validator from pydantic import model_validator from ..annotations import Required -from ..utils import _validate_scim_path_syntax -from .error import Error +from ..path import Path from .message import Message @@ -20,67 +20,22 @@ class SearchRequest(Message): "urn:ietf:params:scim:api:messages:2.0:SearchRequest" ] - attributes: list[str] | None = None + attributes: list[Path[Any]] | None = None """A multi-valued list of strings indicating the names of resource attributes to return in the response, overriding the set of attributes that would be returned by default.""" - @field_validator("attributes") - @classmethod - def validate_attributes_syntax(cls, v: list[str] | None) -> list[str] | None: - """Validate syntax of attribute paths.""" - if v is None: - return v - - for attr in v: - if not _validate_scim_path_syntax(attr): - raise ValueError(Error.make_invalid_path_error().detail) - - return v - - excluded_attributes: list[str] | None = None + excluded_attributes: list[Path[Any]] | None = None """A multi-valued list of strings indicating the names of resource attributes to be removed from the default set of attributes to return.""" - @field_validator("excluded_attributes") - @classmethod - def validate_excluded_attributes_syntax( - cls, v: list[str] | None - ) -> list[str] | None: - """Validate syntax of excluded attribute paths.""" - if v is None: - return v - - for attr in v: - if not _validate_scim_path_syntax(attr): - raise ValueError(Error.make_invalid_path_error().detail) - - return v - filter: str | None = None """The filter string used to request a subset of resources.""" - sort_by: str | None = None + sort_by: Path[Any] | None = None """A string indicating the attribute whose value SHALL be used to order the returned responses.""" - @field_validator("sort_by") - @classmethod - def validate_sort_by_syntax(cls, v: str | None) -> str | None: - """Validate syntax of sort_by attribute path. - - :param v: The sort_by attribute path to validate - :return: The validated sort_by attribute path - :raises ValueError: If sort_by attribute path has invalid syntax - """ - if v is None: - return v - - if not _validate_scim_path_syntax(v): - raise ValueError(Error.make_invalid_path_error().detail) - - return v - class SortOrder(str, Enum): ascending = "ascending" descending = "descending" diff --git a/scim2_models/path.py b/scim2_models/path.py new file mode 100644 index 0000000..800f409 --- /dev/null +++ b/scim2_models/path.py @@ -0,0 +1,738 @@ +import re +from collections import UserString +from collections.abc import Iterator +from inspect import isclass +from typing import TYPE_CHECKING +from typing import Any +from typing import Generic +from typing import NamedTuple +from typing import TypeVar + +from pydantic import GetCoreSchemaHandler +from pydantic_core import core_schema + +from .base import BaseModel +from .utils import _find_field_name +from .utils import _to_camel + +if TYPE_CHECKING: + from .annotations import Mutability + from .annotations import Required + from .resources.resource import Resource + +ResourceT = TypeVar("ResourceT", bound="Resource[Any]") + +_VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$') +_PATH_CACHE: dict[tuple[type, type], type] = {} + + +def _to_comparable(value: Any) -> Any: + """Convert a value to a comparable form (dict for BaseModel).""" + return value.model_dump() if isinstance(value, BaseModel) else value + + +def _values_match(value1: Any, value2: Any) -> bool: + """Check if two values match, handling BaseModel comparison.""" + return bool(_to_comparable(value1) == _to_comparable(value2)) + + +def _value_in_list(current_list: list[Any], new_value: Any) -> bool: + """Check if a value exists in a list, handling BaseModel comparison.""" + return any(_values_match(item, new_value) for item in current_list) + + +def _require_field(model: type[BaseModel], name: str) -> str: + """Find field name or raise PathNotFoundError.""" + if (field_name := _find_field_name(model, name)) is None: + raise PathNotFoundError(f"Field not found: {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.""" + + target: "BaseModel" + path_str: str + is_explicit_schema_path: bool = False + + +class URN(UserString): + @classmethod + def __get_pydantic_core_schema__( + cls, + _source: type[Any], + _handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + return core_schema.no_info_after_validator_function( + cls, + core_schema.str_schema(), + serialization=core_schema.plain_serializer_function_ser_schema( + str, + ), + ) + + def __init__(self, urn: str): + self.check_syntax(urn) + self.data = urn + + @classmethod + def check_syntax(cls, path: str) -> None: + """Validate URN-based path format. + + :param path: The URN path to validate + :raises ValueError: If the URN format is invalid + """ + if not path.startswith("urn:"): + raise ValueError("The URN does not start with urn:") + + urn_segments = path.split(":") + if len(urn_segments) < 3: + raise ValueError("URNs must have at least 3 parts") + + +class Path(UserString, Generic[ResourceT]): + __scim_model__: type[BaseModel] | None = None + + def __class_getitem__(cls, model: type[ResourceT]) -> type["Path[ResourceT]"]: + """Create a Path class bound to a specific model type.""" + if not isclass(model) or not hasattr(model, "model_fields"): + return super().__class_getitem__(model) # type: ignore[misc,no-any-return] + + cache_key = (cls, model) + if cache_key in _PATH_CACHE: + return _PATH_CACHE[cache_key] + + new_class = type(f"Path[{model.__name__}]", (cls,), {"__scim_model__": model}) + _PATH_CACHE[cache_key] = new_class + return new_class + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: type[Any], + _handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + def validate_path(value: Any) -> "Path[Any]": + if isinstance(value, Path): + return cls(str(value)) + if isinstance(value, str): + return cls(value) + raise ValueError(f"Expected str or Path, got {type(value).__name__}") + + return core_schema.no_info_plain_validator_function( + validate_path, + serialization=core_schema.plain_serializer_function_ser_schema(str), + ) + + def __init__(self, path: str): + self.check_syntax(path) + self.data = path + + @classmethod + def check_syntax(cls, path: str) -> None: + """Check if path syntax is valid according to RFC 7644 simplified rules. + + An empty string is valid and represents the resource root. + + :param path: The path to validate + :raises ValueError: If the path syntax is invalid + """ + if not path: + return + + if path[0].isdigit(): + raise ValueError("Paths cannot start with a digit") + + if ".." in path: + raise ValueError("Paths cannot contain double dots") + + if not _VALID_PATH_PATTERN.match(path): + raise ValueError("The path contains invalid characters") + + if path.endswith(":"): + raise ValueError("Paths cannot end with a colon") + + if ":" in path: + urn = path.rsplit(":", 1)[0] + try: + URN(urn.lower()) + except ValueError as exc: + raise ValueError(f"The path is not a valid URN: {exc}") from exc + + @property + def schema(self) -> str | None: + """The schema URN portion of the path. + + For paths like "urn:...:User:userName", returns "urn:...:User". + For simple paths like "userName", returns None. + """ + if ":" not in self.data: + return None + return self.data.rsplit(":", 1)[0] + + @property + def attr(self) -> str: + """The attribute portion of the path. + + For paths like "urn:...:User:userName", returns "userName". + For simple paths like "userName", returns "userName". + For schema-only paths like "urn:...:User", returns "". + """ + if ":" not in self.data: + return self.data + return self.data.rsplit(":", 1)[1] + + @property + def parts(self) -> tuple[str, ...]: + """The attribute path segments split by '.'. + + For "name.familyName", returns ("name", "familyName"). + For "userName", returns ("userName",). + For "", returns (). + """ + attr = self.attr + if not attr: + return () + return tuple(attr.split(".")) + + def is_prefix_of(self, other: "str | Path[Any]") -> bool: + """Check if this path is a prefix of another path. + + A path is a prefix if the other path starts with this path + followed by a separator ("." or ":"). + + Examples:: + + Path("emails").is_prefix_of("emails.value") # True + Path("emails").is_prefix_of("emails") # False (equal, not prefix) + Path("urn:...:User").is_prefix_of("urn:...:User:name") # True + """ + other_str = str(other).lower() + self_str = self.data.lower() + + if self_str == other_str: + return False + + return other_str.startswith(f"{self_str}.") or other_str.startswith( + f"{self_str}:" + ) + + def has_prefix(self, prefix: "str | Path[Any]") -> bool: + """Check if this path has the given prefix. + + Examples:: + + Path("emails.value").has_prefix("emails") # True + Path("emails").has_prefix("emails") # False (equal, not prefix) + Path("urn:...:User:name").has_prefix("urn:...:User") # True + """ + prefix_path = prefix if isinstance(prefix, Path) else Path(str(prefix)) + return prefix_path.is_prefix_of(self) + + @property + def model(self) -> type[BaseModel] | None: + """The target model type for this path. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound or invalid. + + For "name.familyName" on Path[User], returns Name. + For "userName" on Path[User], returns User. + """ + if (result := self._resolve_model()) is None: + return None + return result[0] + + @property + def field_name(self) -> str | None: + """The Python attribute name (snake_case) for this path. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound or invalid. + + For "name.familyName" on Path[User], returns "family_name". + For "userName" on Path[User], returns "user_name". + """ + if (result := self._resolve_model()) is None: + return None + return result[1] + + @property + def field_type(self) -> type | None: + """The Python type of the field this path points to. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound, invalid, or points to a schema-only path. + + For "userName" on Path[User], returns str. + For "name" on Path[User], returns Name. + For "emails" on Path[User], returns Email. + """ + if self.model is None or self.field_name is None: + return None + return self.model.get_field_root_type(self.field_name) + + @property + def is_multivalued(self) -> bool | None: + """Whether this path points to a multi-valued attribute. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound, invalid, or points to a schema-only path. + + For "emails" on Path[User], returns True. + For "userName" on Path[User], returns False. + """ + if self.model is None or self.field_name is None: + return None + return self.model.get_field_multiplicity(self.field_name) + + def get_annotation(self, annotation_type: type) -> Any: + """Get annotation value for this path's field. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound, invalid, or points to a schema-only path. + + :param annotation_type: The annotation class (e.g., Required, Mutability). + :returns: The annotation value or None. + + For "userName" on Path[User] with Required, returns Required.true. + """ + if self.model is None or self.field_name is None: + return None + return self.model.get_field_annotation(self.field_name, annotation_type) + + @property + def urn(self) -> str | None: + """The fully qualified URN for this path. + + Requires the Path to be bound to a model type via ``Path[Model]``. + Returns None if the path is unbound or invalid. + + For "userName" on Path[User], returns + "urn:ietf:params:scim:schemas:core:2.0:User:userName". + """ + from .resources.resource import Resource + + if self.__scim_model__ is None or self.model is None: + return None + + schema = self.schema + if not schema and issubclass(self.__scim_model__, Resource): + schema = self.__scim_model__.model_fields["schemas"].default[0] + + if not self.attr: + return schema if schema else None + return f"{schema}:{self.attr}" if schema else self.attr + + def _resolve_model(self) -> tuple[type[BaseModel], str | None] | None: + """Resolve the path against the bound model type.""" + from .resources.resource import Extension + from .resources.resource import Resource + + model = self.__scim_model__ + if model is None: + return None + + attr_path = self.attr + + if ":" in self and isclass(model) and issubclass(model, Resource | Extension): + model_schema = model.model_fields["schemas"].default[0] + path_lower = str(self).lower() + + if path_lower == model_schema.lower(): + return model, None + elif path_lower.startswith(model_schema.lower()): + attr_path = str(self)[len(model_schema) :].lstrip(":") + elif issubclass(model, Resource): + for ( + extension_schema, + extension_model, + ) in model.get_extension_models().items(): + schema_lower = extension_schema.lower() + if path_lower == schema_lower: + return extension_model, None + elif path_lower.startswith(schema_lower): + model = extension_model + break + else: + return None + + if not attr_path: + return model, None + + if "." in attr_path: + parts = attr_path.split(".") + current_model = model + + for part in parts[:-1]: + if (field_name := _find_field_name(current_model, part)) is None: + return None + field_type = current_model.get_field_root_type(field_name) + if ( + field_type is None + or not isclass(field_type) + or not issubclass(field_type, BaseModel) + ): + return None + current_model = field_type + + if (field_name := _find_field_name(current_model, parts[-1])) is None: + return None + return current_model, field_name + + if (field_name := _find_field_name(model, attr_path)) is None: + return None + return model, field_name + + def _resolve_instance( + self, resource: BaseModel, *, create: bool = False + ) -> _Resolution | None: + """Resolve the target object and remaining path. + + :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. + """ + from .resources.resource import Extension + from .resources.resource import Resource + + path_str = str(self) + + if ":" not in path_str: + return _Resolution(resource, path_str) + + model_schema = type(resource).model_fields["schemas"].default[0] + path_lower = path_str.lower() + + if isinstance(resource, Resource | Extension) and path_lower.startswith( + model_schema.lower() + ): + is_explicit = path_lower == model_schema.lower() + normalized = path_str[len(model_schema) :].lstrip(":") + return _Resolution(resource, normalized, is_explicit) + + if isinstance(resource, Resource): + for ext_schema, ext_model in resource.get_extension_models().items(): + ext_schema_lower = ext_schema.lower() + if path_lower == ext_schema_lower: + return _Resolution(resource, ext_model.__name__) + if path_lower.startswith(ext_schema_lower): + sub_path = path_str[len(ext_schema) :].lstrip(":") + ext_obj = getattr(resource, ext_model.__name__) + if create and ext_obj is None: + ext_obj = ext_model() + setattr(resource, ext_model.__name__, ext_obj) + if ext_obj is None: + return None + return _Resolution(ext_obj, sub_path) + + raise InvalidPathError(f"Extension not found for path: {self}") + + return None + + def _walk_to_target( + self, obj: BaseModel, path_str: str + ) -> tuple[BaseModel, str] | None: + """Navigate to the target object and field. + + :returns: (target_obj, field_name) or None if an intermediate is None. + """ + if "." not in path_str: + return obj, _require_field(type(obj), path_str) + + parts = path_str.split(".") + current_obj = obj + + for part in parts[:-1]: + field_name = _require_field(type(current_obj), part) + if (current_obj := getattr(current_obj, field_name)) is None: + return None + + return current_obj, _require_field(type(current_obj), parts[-1]) + + def _get(self, resource: ResourceT) -> Any: + """Get the value at this path from a resource.""" + if (resolution := self._resolve_instance(resource)) is None: + return None + + if not resolution.path_str: + return resolution.target + + if ( + result := self._walk_to_target(resolution.target, resolution.path_str) + ) is None: + return None + + obj, field_name = result + return getattr(obj, field_name) + + def get(self, resource: ResourceT, *, strict: bool = True) -> Any: + """Get the value at this path from a resource. + + :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. + """ + try: + return self._get(resource) + except PathError: + if strict: + raise + return None + + def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool: + """Set a value at this path on a resource.""" + if (resolution := self._resolve_instance(resource, create=True)) is None: + return False + + obj = resolution.target + path_str = resolution.path_str + is_explicit_schema_path = resolution.is_explicit_schema_path + + if not path_str: + if not isinstance(value, dict): + if is_explicit_schema_path: + raise InvalidPathError(f"Schema path requires dict value: {self}") + return False + filtered_value = { + k: v + for k, v in value.items() + if _find_field_name(type(obj), k) is not None + } + if not filtered_value: + return False + old_data = obj.model_dump() + updated_data = {**old_data, **filtered_value} + if updated_data == old_data: + return False + updated_obj = type(obj).model_validate(updated_data) + obj.__dict__.update(updated_obj.__dict__) + return True + + if "." not in path_str: + field_name = _require_field(type(obj), path_str) + return self._set_field_value(obj, field_name, value, is_add) + + parts = path_str.split(".") + current_obj = obj + + for part in parts[:-1]: + field_name = _require_field(type(current_obj), part) + if (sub_obj := getattr(current_obj, field_name)) is None: + field_type = type(current_obj).get_field_root_type(field_name) + if field_type is None or field_type is Any or not isclass(field_type): + return False + sub_obj = field_type() + setattr(current_obj, field_name, sub_obj) + elif isinstance(sub_obj, list): + return False + current_obj = sub_obj + + field_name = _require_field(type(current_obj), parts[-1]) + return self._set_field_value(current_obj, field_name, value, is_add) + + def set( + self, + resource: ResourceT, + value: Any, + *, + is_add: bool = False, + strict: bool = True, + ) -> bool: + """Set a value at this path on a resource. + + :param resource: The resource to set the value on. + :param value: The value to set. + :param is_add: If True and the target is multi-valued, append to the + 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. + """ + try: + return self._set(resource, value, is_add=is_add) + except PathError: + if strict: + raise + return False + + @staticmethod + def _set_field_value( + obj: BaseModel, field_name: str, value: Any, is_add: bool + ) -> bool: + """Set or add a value to a field.""" + is_multivalued = obj.get_field_multiplicity(field_name) + + if is_add and is_multivalued: + current_list = getattr(obj, field_name) or [] + if isinstance(value, list): + new_values = [v for v in value if not _value_in_list(current_list, v)] + if not new_values: + return False + setattr(obj, field_name, current_list + new_values) + else: + if _value_in_list(current_list, value): + return False + current_list.append(value) + setattr(obj, field_name, current_list) + return True + + if is_multivalued and not isinstance(value, list) and value is not None: + value = [value] + + old_value = getattr(obj, field_name) + if old_value == value: + return False + + setattr(obj, field_name, value) + return True + + def _delete(self, resource: ResourceT, value: Any | None = None) -> bool: + """Delete a value at this path from a resource.""" + if (resolution := self._resolve_instance(resource)) is None: + return False + + if not resolution.path_str: + raise InvalidPathError(f"Cannot delete schema-only path: {self}") + + if ( + result := self._walk_to_target(resolution.target, resolution.path_str) + ) is None: + return False + + obj, field_name = result + if (current_value := getattr(obj, field_name)) is None: + return False + + if value is not None: + if not isinstance(current_value, list): + return False + new_list = [ + item for item in current_value if not _values_match(item, value) + ] + if len(new_list) == len(current_value): + return False + setattr(obj, field_name, new_list if new_list else None) + return True + + setattr(obj, field_name, None) + return True + + def delete( + self, resource: ResourceT, value: Any | None = None, *, strict: bool = True + ) -> bool: + """Delete a value at this path from a resource. + + If value is None, the entire attribute is set to None. + If value is provided and the attribute is multi-valued, + only matching values are removed from the list. + + :param resource: The resource to delete the value from. + :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. + """ + try: + return self._delete(resource, value) + except PathError: + if strict: + raise + return False + + @classmethod + def iter_paths( + cls, + include_subattributes: bool = True, + include_extensions: bool = True, + required: "list[Required] | None" = None, + mutability: "list[Mutability] | None" = None, + ) -> "Iterator[Path[ResourceT]]": + """Iterate over all paths for the bound model and its extensions. + + Requires the Path to be bound to a model type via ``Path[Model]``. + + :param include_subattributes: Whether to include sub-attribute paths. + :param include_extensions: Whether to include extension attributes. + :param required: Filter by Required annotation values (e.g., [Required.true]). + :param mutability: Filter by Mutability annotation values (e.g., [Mutability.read_write]). + :yields: Path instances for each attribute matching the filters. + """ + from .annotations import Mutability + from .annotations import Required + from .attributes import ComplexAttribute + from .resources.resource import Extension + from .resources.resource import Resource + + model = cls.__scim_model__ + if model is None: + raise TypeError("iter_paths requires a bound Path type: Path[Model]") + + def matches_filters(target_model: type[BaseModel], field_name: str) -> bool: + if required is not None: + field_required = target_model.get_field_annotation(field_name, Required) + if field_required not in required: + return False + if mutability is not None: + field_mutability = target_model.get_field_annotation( + field_name, Mutability + ) + if field_mutability not in mutability: + return False + return True + + def iter_model_paths( + target_model: type[Resource[Any] | Extension], + ) -> "Iterator[Path[ResourceT]]": + for field_name in target_model.model_fields: + if field_name in ("meta", "id", "schemas"): + continue + + if not matches_filters(target_model, field_name): + continue + + field_type = target_model.get_field_root_type(field_name) + + if isclass(field_type) and issubclass(field_type, Extension): + if not include_extensions: + continue + urn = field_type.model_fields["schemas"].default[0] + elif isclass(target_model) and issubclass(target_model, Extension): + urn = target_model().get_attribute_urn(field_name) + else: + urn = _to_camel(field_name) + + yield cls(urn) + + is_complex = ( + field_type is not None + and isclass(field_type) + and issubclass(field_type, ComplexAttribute) + ) + if include_subattributes and is_complex: + for sub_field_name in field_type.model_fields: # type: ignore[union-attr] + if not matches_filters(field_type, sub_field_name): # type: ignore[arg-type] + continue + sub_urn = f"{urn}.{_to_camel(sub_field_name)}" + yield cls(sub_urn) + + yield from iter_model_paths(model) # type: ignore[arg-type] + + if include_extensions and isclass(model) and issubclass(model, Resource): + for extension_model in model.get_extension_models().values(): + yield from iter_model_paths(extension_model) diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 90b295c..7eb8ccb 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -5,7 +5,6 @@ from typing import Generic from typing import TypeVar from typing import Union -from typing import cast from typing import get_args from typing import get_origin @@ -24,9 +23,10 @@ from ..attributes import is_complex_attribute from ..base import BaseModel from ..context import Context +from ..path import Path +from ..path import PathError from ..reference import Reference from ..scim_object import ScimObject -from ..urn import _validate_attribute_urn from ..utils import UNION_TYPES from ..utils import _normalize_attribute_name @@ -206,17 +206,72 @@ def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]: return new_class - def __getitem__(self, item: Any) -> Extension | None: - if not isinstance(item, type) or not issubclass(item, Extension): - raise KeyError(f"{item} is not a valid extension type") + def __getitem__(self, item: Any) -> Any: + """Get a value by extension type or path. - return cast(Extension | None, getattr(self, item.__name__)) + :param item: An Extension subclass or a path (string or Path). + :returns: The extension instance or the value at the path. + :raises KeyError: If the path references a non-existent field. - def __setitem__(self, item: Any, value: "Extension") -> None: - if not isinstance(item, type) or not issubclass(item, Extension): - raise KeyError(f"{item} is not a valid extension type") + Examples:: - setattr(self, item.__name__, value) + user[EnterpriseUser] # Get extension + user["userName"] # Get attribute + user["name.familyName"] # Get nested attribute + """ + if isinstance(item, type) and issubclass(item, Extension): + item = item.model_fields["schemas"].default[0] + + bound_path = Path.__class_getitem__(type(self)) + path = item if isinstance(item, Path) else bound_path(str(item)) + try: + return path.get(self) + except PathError as exc: + raise KeyError(str(item)) from exc + + def __setitem__(self, item: Any, value: Any) -> None: + """Set a value by extension type or path. + + :param item: An Extension subclass or a path (string or Path). + :param value: The value to set. + :raises KeyError: If the path references a non-existent field. + + Examples:: + + user[EnterpriseUser] = EnterpriseUser(employee_number="123") + user["displayName"] = "John Doe" + user["name.familyName"] = "Doe" + """ + if isinstance(item, type) and issubclass(item, Extension): + item = item.model_fields["schemas"].default[0] + + bound_path = Path.__class_getitem__(type(self)) + path = item if isinstance(item, Path) else bound_path(str(item)) + try: + path.set(self, value) + except PathError as exc: + raise KeyError(str(item)) from exc + + def __delitem__(self, item: Any) -> None: + """Delete a value by extension type or path. + + :param item: An Extension subclass or a path (string or Path). + :raises KeyError: If the path references a non-existent field. + + Examples:: + + del user[EnterpriseUser] # Remove extension + del user["displayName"] # Remove attribute + """ + if isinstance(item, type) and issubclass(item, Extension): + item = item.model_fields["schemas"].default[0] + + bound_path = Path.__class_getitem__(type(self)) + path = item if isinstance(item, Path) else bound_path(str(item)) + try: + path.delete(self) + except PathError as exc: + raise KeyError(str(item)) from exc @classmethod def get_extension_models(cls) -> dict[str, type[Extension]]: @@ -305,17 +360,16 @@ def _prepare_model_dump( kwargs = super()._prepare_model_dump(scim_ctx, **kwargs) # RFC 7644: "SHOULD ignore any query parameters they do not recognize" + bound_path = Path.__class_getitem__(type(self)) kwargs["context"]["scim_attributes"] = [ - valid_attr + urn for attribute in (attributes or []) - if (valid_attr := _validate_attribute_urn(attribute, self.__class__)) - is not None + if (urn := bound_path(attribute).urn) is not None ] kwargs["context"]["scim_excluded_attributes"] = [ - valid_attr + urn for attribute in (excluded_attributes or []) - if (valid_attr := _validate_attribute_urn(attribute, self.__class__)) - is not None + if (urn := bound_path(attribute).urn) is not None ] return kwargs diff --git a/scim2_models/resources/schema.py b/scim2_models/resources/schema.py index 669e159..d15d376 100644 --- a/scim2_models/resources/schema.py +++ b/scim2_models/resources/schema.py @@ -288,7 +288,7 @@ def get_attribute(self, attribute_name: str) -> Attribute | None: return attribute return None - def __getitem__(self, name: str) -> "Attribute": # type: ignore[override] + def __getitem__(self, name: str) -> "Attribute": """Find an attribute by its name.""" if attribute := self.get_attribute(name): return attribute diff --git a/scim2_models/urn.py b/scim2_models/urn.py deleted file mode 100644 index 5a2756e..0000000 --- a/scim2_models/urn.py +++ /dev/null @@ -1,126 +0,0 @@ -from typing import TYPE_CHECKING -from typing import Any -from typing import Union - -from .base import BaseModel -from .utils import _get_path_parts -from .utils import _normalize_attribute_name - -if TYPE_CHECKING: - from .base import BaseModel - from .resources.resource import Extension - from .resources.resource import Resource - - -def _get_or_create_extension_instance( - model: "Resource[Any]", extension_class: type -) -> "Extension": - """Get existing extension instance or create a new one.""" - extension_instance = model[extension_class] - if extension_instance is None: - extension_instance = extension_class() - model[extension_class] = extension_instance - return extension_instance - - -def _normalize_path(model: type["BaseModel"] | None, path: str) -> tuple[str, str]: - """Resolve a path to (schema_urn, attribute_path).""" - from .resources.resource import Resource - - # Absolute URN - if ":" in path: - if ( - model - and issubclass(model, Resource) - and ( - path in model.get_extension_models() - or path == model.model_fields["schemas"].default[0] - ) - ): - return path, "" - - parts = path.rsplit(":", 1) - return parts[0], parts[1] - - # Relative URN with a schema - elif model and issubclass(model, Resource) and hasattr(model, "model_fields"): - schemas_field = model.model_fields.get("schemas") - return schemas_field.default[0], path # type: ignore - - return "", path - - -def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None: - """Validate that an attribute name or a sub-attribute path exist for a given model.""" - attribute_name, *sub_attribute_blocks = _get_path_parts(attribute_base) - sub_attribute_base = ".".join(sub_attribute_blocks) - - aliases = {field.validation_alias for field in model.model_fields.values()} - - if _normalize_attribute_name(attribute_name) not in aliases: - raise ValueError( - f"Model '{model.__name__}' has no attribute named '{attribute_name}'" - ) - - if sub_attribute_base: - attribute_type = model.get_field_root_type(attribute_name) - - if not attribute_type or not issubclass(attribute_type, BaseModel): - raise ValueError( - f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute" - ) - - _validate_model_attribute(attribute_type, sub_attribute_base) - - -def _validate_attribute_urn( - attribute_name: str, resource: type["Resource[Any]"] -) -> str | None: - """Validate that an attribute urn is valid or not. - - :param attribute_name: The attribute urn to check. - :return: The normalized attribute URN. - """ - from .resources.resource import Resource - - schema: Any | None - schema, attribute_base = _normalize_path(resource, attribute_name) - - validated_resource = Resource.get_by_schema([resource], schema) - if not validated_resource: - return None - - try: - _validate_model_attribute(validated_resource, attribute_base) - except ValueError: - return None - - return f"{schema}:{attribute_base}" - - -def _resolve_path_to_target( - resource: "Resource[Any]", path: str -) -> tuple[Union["Resource[Any]", "Extension"] | None, str]: - """Resolve a path to a target and an attribute_path. - - The target can be the resource itself, or an extension object. - """ - schema_urn, attr_path = _normalize_path(type(resource), path) - - if not schema_urn: - return resource, attr_path - - if extension_class := resource.get_extension_model(schema_urn): - # Points to the extension root - if not attr_path: - return resource, extension_class.__name__ - - extension_instance = _get_or_create_extension_instance( - resource, extension_class - ) - return extension_instance, attr_path - - if schema_urn in resource.schemas: - return resource, attr_path - - return (None, "") diff --git a/scim2_models/utils.py b/scim2_models/utils.py index 50d283a..c85d7b3 100644 --- a/scim2_models/utils.py +++ b/scim2_models/utils.py @@ -23,7 +23,6 @@ _UNDERSCORE_ALPHANUMERIC = re.compile(r"_+([0-9A-Za-z]+)") _NON_WORD_UNDERSCORE = re.compile(r"[\W_]+") -_VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$') def _int_to_str(status: int | None) -> str | None: @@ -106,84 +105,6 @@ def _normalize_attribute_name(attribute_name: str) -> str: return attribute_name.lower() -def _validate_scim_path_syntax(path: str) -> bool: - """Check if path syntax is valid according to RFC 7644 simplified rules. - - :param path: The path to validate - :return: True if path syntax is valid, False otherwise - """ - if not path or not path.strip(): - return False - - # Cannot start with a digit - if path[0].isdigit(): - return False - - # Cannot contain double dots - if ".." in path: - return False - - # Cannot contain invalid characters (basic check) - # Allow alphanumeric, dots, underscores, hyphens, colons (for URNs), brackets - if not _VALID_PATH_PATTERN.match(path): - return False - - # If it contains a colon, validate it's a proper URN format - if ":" in path: - if not _validate_scim_urn_syntax(path): - return False - - return True - - -def _validate_scim_urn_syntax(path: str) -> bool: - """Validate URN-based path format. - - :param path: The URN path to validate - :return: True if URN path format is valid, False otherwise - """ - # Basic URN validation: should start with urn: - if not path.startswith("urn:"): - return False - - # Split on the last colon to separate URN from attribute - urn_part, attr_part = path.rsplit(":", 1) - - # URN part should have at least 4 parts (urn:namespace:specific:resource) - urn_segments = urn_part.split(":") - if len(urn_segments) < 4: - return False - - # Attribute part should be valid - if not attr_part or attr_part[0].isdigit(): - return False - - return True - - -def _extract_field_name(path: str) -> str | None: - """Extract the field name from a path. - - For now, only handle simple paths (no filters, no complex expressions). - Returns None for complex paths that require filter parsing. - - """ - # Handle URN paths - if path.startswith("urn:"): - # First validate it's a proper URN - if not _validate_scim_urn_syntax(path): - return None - parts = path.rsplit(":", 1) - return parts[1] - - # Simple attribute path (may have dots for sub-attributes) - # For now, just take the first part before any dot - if "." in path: - return path.split(".")[0] - - return path - - def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> str | None: """Find the actual field name in a resource class from an attribute name. @@ -198,7 +119,3 @@ def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> str | No return field_key return None - - -def _get_path_parts(path: str) -> list[str]: - return path.split(".") diff --git a/tests/test_model_attributes.py b/tests/test_model_attributes.py index 2ac8223..197b22b 100644 --- a/tests/test_model_attributes.py +++ b/tests/test_model_attributes.py @@ -16,7 +16,6 @@ from scim2_models.resources.resource import Meta from scim2_models.resources.resource import Resource from scim2_models.resources.user import User -from scim2_models.urn import _validate_attribute_urn class Sub(ComplexAttribute): @@ -72,51 +71,6 @@ class MyExtension(Extension): baz: str -def test_validate_attribute_urn(): - """Test the method that validates and normalizes attribute URNs.""" - assert _validate_attribute_urn("bar", Foo) == "urn:example:2.0:Foo:bar" - assert ( - _validate_attribute_urn("urn:example:2.0:Foo:bar", Foo) - == "urn:example:2.0:Foo:bar" - ) - - assert _validate_attribute_urn("sub", Foo) == "urn:example:2.0:Foo:sub" - assert ( - _validate_attribute_urn("urn:example:2.0:Foo:sub", Foo) - == "urn:example:2.0:Foo:sub" - ) - - assert ( - _validate_attribute_urn("sub.always", Foo) == "urn:example:2.0:Foo:sub.always" - ) - assert ( - _validate_attribute_urn("urn:example:2.0:Foo:sub.always", Foo) - == "urn:example:2.0:Foo:sub.always" - ) - - assert _validate_attribute_urn("snakeCase", Foo) == "urn:example:2.0:Foo:snakeCase" - assert ( - _validate_attribute_urn("urn:example:2.0:Foo:snakeCase", Foo) - == "urn:example:2.0:Foo:snakeCase" - ) - - assert ( - _validate_attribute_urn("urn:example:2.0:MyExtension:baz", Foo[MyExtension]) - == "urn:example:2.0:MyExtension:baz" - ) - - assert _validate_attribute_urn("urn:InvalidResource:bar", Foo) is None - - assert _validate_attribute_urn("urn:example:2.0:Foo:invalid", Foo) is None - - assert _validate_attribute_urn("bar.invalid", Foo) is None - - assert ( - _validate_attribute_urn("urn:example:2.0:MyExtension:invalid", Foo[MyExtension]) - is None - ) - - def test_payload_attribute_case_sensitivity(): """RFC7643 §2.1 indicates that attribute names should be case insensitive. diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index 12c451b..975437c 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -18,7 +18,7 @@ class SubRetModel(ComplexAttribute): class SupRetResource(Resource): - schemas: Annotated[list[str], Required.true] = ["org:example:SupRetResource"] + schemas: Annotated[list[str], Required.true] = ["urn:org:example:SupRetResource"] always_returned: Annotated[str | None, Returned.always] = None never_returned: Annotated[str | None, Returned.never] = None @@ -175,7 +175,7 @@ def test_dump_search_request(mut_resource): def test_dump_default_response(ret_resource): """When no scim context is passed, every attributes are dumped.""" assert ret_resource.model_dump() == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "neverReturned": "x", @@ -200,7 +200,7 @@ def test_invalid_attributes(): ) # Should return default response (alwaysReturned attributes) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -208,10 +208,10 @@ def test_invalid_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes={"org:example:SupRetResource:invalidAttribute"}, + attributes={"urn:org:example:SupRetResource:invalidAttribute"}, ) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -222,7 +222,7 @@ def test_invalid_attributes(): attributes={"urn:invalid:schema:invalidAttribute"}, ) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -240,7 +240,7 @@ def test_invalid_excluded_attributes(): ) # Should return default response (nothing excluded) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -248,10 +248,10 @@ def test_invalid_excluded_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes={"org:example:SupRetResource:invalidAttribute"}, + excluded_attributes={"urn:org:example:SupRetResource:invalidAttribute"}, ) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -262,7 +262,7 @@ def test_invalid_excluded_attributes(): excluded_attributes={"urn:invalid:schema:invalidAttribute"}, ) assert result == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -290,7 +290,7 @@ def test_dump_response(context, ret_resource): Including attributes with 'attributes=' replace the whole default set. """ assert ret_resource.model_dump(scim_ctx=context) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -301,13 +301,13 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump(scim_ctx=context, attributes={"alwaysReturned"}) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", } assert ret_resource.model_dump(scim_ctx=context, attributes={"neverReturned"}) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", } @@ -315,14 +315,14 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, attributes={"defaultReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", } assert ret_resource.model_dump(scim_ctx=context, attributes={"sub"}) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "sub": { @@ -334,7 +334,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, attributes={"sub.defaultReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "sub": { @@ -346,7 +346,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, attributes={"requestReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "requestReturned": "x", @@ -356,7 +356,7 @@ def test_dump_response(context, ret_resource): scim_ctx=context, attributes={"defaultReturned", "requestReturned"}, ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -366,7 +366,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, excluded_attributes={"alwaysReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -379,7 +379,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, excluded_attributes={"neverReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -392,7 +392,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, excluded_attributes={"defaultReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "sub": { @@ -404,7 +404,7 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, excluded_attributes={"requestReturned"} ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "defaultReturned": "x", @@ -418,7 +418,7 @@ def test_dump_response(context, ret_resource): scim_ctx=context, excluded_attributes={"defaultReturned", "requestReturned"}, ) == { - "schemas": ["org:example:SupRetResource"], + "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", "sub": { diff --git a/tests/test_patch_op_add.py b/tests/test_patch_op_add.py index 082bc23..64da5a3 100644 --- a/tests/test_patch_op_add.py +++ b/tests/test_patch_op_add.py @@ -10,7 +10,9 @@ def test_add_operation_single_attribute(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Babs") + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Babs" + ) ] ) result = patch.patch(user) @@ -23,7 +25,9 @@ def test_add_operation_single_attribute_already_present(): user = User(nick_name="foobar") patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Babs") + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Babs" + ) ] ) result = patch.patch(user) @@ -36,7 +40,9 @@ def test_add_operation_same_value(): user = User(nick_name="Test") patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.add, path="nickName", value="Test") + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="Test" + ) ] ) result = patch.patch(user) @@ -49,7 +55,7 @@ def test_add_operation_sub_attribute(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="name.familyName", value="Jensen" ) ] @@ -64,7 +70,7 @@ def test_add_operation_complex_attribute(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="name", value={"familyName": "Jensen"} ) ] @@ -81,7 +87,7 @@ def test_add_operation_creates_parent_complex_object(): # Add to a sub-attribute when parent doesn't exist patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="name.givenName", value="John" ) ] @@ -98,7 +104,7 @@ def test_add_operation_multiple_attribute(): group = Group() patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.add, path="members", value=[ @@ -128,7 +134,7 @@ def test_add_operation_multiple_attribute_already_present(): patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.add, path="members", value=[member.model_dump()], @@ -149,7 +155,7 @@ def test_add_operation_single_value_in_multivalued_field(): group = Group(members=[]) patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.add, path="members", value={ @@ -182,7 +188,7 @@ def test_add_multiple_values_empty_new_values(): group = Group(members=[member1, member2]) patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.add, path="members", value=[ @@ -203,7 +209,7 @@ def test_add_single_value_existing(): group = Group(members=[GroupMember(value="123", display="Test User")]) patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.add, path="members", value={"value": "123", "display": "Test User"}, # Same value @@ -221,7 +227,7 @@ def test_add_operation_no_path(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, value={ "emails": [{"value": "babs@jensen.org", "type": "home"}], @@ -241,7 +247,7 @@ def test_add_operation_no_path_same_attributes(): user = User(nick_name="Test") patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, value={"nickName": "Test"}, ) @@ -257,7 +263,7 @@ def test_add_operation_no_path_with_invalid_attribute(): user = User(nick_name="Test") patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, value={"invalidAttributeName": "value", "nickName": "Updated"}, ) @@ -272,7 +278,9 @@ def test_add_operation_with_non_dict_value_no_path(): """Test add operation with no path and non-dict value should return False.""" user = User() patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.add, value="invalid_value")] + operations=[ + PatchOperation[User](op=PatchOperation.Op.add, value="invalid_value") + ] ) result = patch.patch(user) assert result is False diff --git a/tests/test_patch_op_extensions.py b/tests/test_patch_op_extensions.py index 0e3dcae..41afd71 100644 --- a/tests/test_patch_op_extensions.py +++ b/tests/test_patch_op_extensions.py @@ -2,6 +2,7 @@ import pytest from pydantic import Field +from pydantic import ValidationError from scim2_models import Group from scim2_models import GroupMember @@ -26,7 +27,7 @@ def test_patch_operation_extension_simple_attribute(): patch1 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", value="54321", @@ -39,7 +40,7 @@ def test_patch_operation_extension_simple_attribute(): patch2 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization", value="ACME Corp", @@ -52,7 +53,7 @@ def test_patch_operation_extension_simple_attribute(): patch3 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter", ) @@ -77,7 +78,7 @@ def test_patch_operation_extension_complex_attribute(): patch1 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value", value="new-manager-456", @@ -91,7 +92,7 @@ def test_patch_operation_extension_complex_attribute(): patch2 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", value={ @@ -109,7 +110,7 @@ def test_patch_operation_extension_complex_attribute(): patch3 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager", ) @@ -139,7 +140,7 @@ def test_patch_operation_extension_mutability_handled_by_model(): # but patch method assumes operations are already validated patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", value="12345", @@ -151,56 +152,51 @@ def test_patch_operation_extension_mutability_handled_by_model(): assert user[EnterpriseUser].employee_number == "12345" -def test_patch_operation_extension_no_target_error(): - """Test noTarget error for invalid extension paths. +def test_patch_operation_extension_invalid_path_error(): + """Test invalidPath error for non-existent extension attributes. - :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>`: noTarget errors apply to - extension attributes when the specified path does not exist. + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`: invalidPath errors occur when + the path references an attribute that doesn't exist in the schema. """ user = User[EnterpriseUser].model_validate({"userName": "test.user"}) patch1 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:invalidAttribute", value="test", ) ] ) - with pytest.raises(ValueError, match="no match"): + with pytest.raises(ValueError, match="path.*invalid"): patch1.patch(user) patch2 = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.invalidField", value="test", ) ] ) - with pytest.raises(ValueError, match="no match"): + with pytest.raises(ValueError, match="path.*invalid"): patch2.patch(user) def test_urn_parsing_errors(): """Test URN parsing errors for malformed URNs.""" - user = User() - - # Test with malformed URN that causes extract_schema_and_attribute_base to fail - patch = PatchOp[User]( - operations=[ - PatchOperation( - op=PatchOperation.Op.add, path="urn:malformed:incomplete", value="test" - ) - ] - ) - - with pytest.raises( - ValueError, match='The "path" attribute was invalid or malformed' - ): - patch.patch(user) + with pytest.raises(ValidationError, match="The path is not a valid URN"): + PatchOp[User]( + operations=[ + PatchOperation[User]( + op=PatchOperation.Op.add, + path="urn:malformed:incomplete", + value="test", + ) + ] + ) def test_values_match_integration(): @@ -212,7 +208,7 @@ def test_values_match_integration(): # Remove with exact matching dict patch_op = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.remove, path="members", value={"value": "123", "display": "Test User"}, @@ -229,7 +225,7 @@ def test_values_match_integration(): patch_op = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.remove, path="members", value={"value": "123", "display": "Test User"}, @@ -260,24 +256,6 @@ def test_generic_patchop_with_single_type(): assert patch.operations[0].value == "test.user" -def test_malformed_urn_extract_error(): - """Test URN extraction error with truly malformed URN.""" - user = User() - patch = PatchOp[User]( - operations=[ - PatchOperation( - op=PatchOperation.Op.add, path="urn:malformed:incomplete", value="test" - ) - ] - ) - - # Should raise error for malformed URN - with pytest.raises( - ValueError, match='The "path" attribute was invalid or malformed' - ): - patch.patch(user) - - def test_create_parent_object_return_none(): """Test _create_parent_object returns None when field type is not a class.""" T = TypeVar("T") @@ -290,7 +268,7 @@ class TestResourceTypeVar(Resource): user = TestResourceTypeVar() patch = PatchOp[TestResourceTypeVar]( operations=[ - PatchOperation( + PatchOperation[TestResourceTypeVar]( op=PatchOperation.Op.add, path="typevarField.subfield", value="test" ) ] @@ -308,7 +286,7 @@ def test_complex_object_creation_and_basemodel_matching(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="name.givenName", value="John" ) ] @@ -322,7 +300,7 @@ def test_complex_object_creation_and_basemodel_matching(): group = Group(members=[GroupMember(value="123", display="Test")]) patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.remove, path="members", value=GroupMember(value="123", display="Test"), @@ -347,7 +325,7 @@ def test_patch_extension_schema_path_without_attribute(): patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", value={ @@ -370,7 +348,7 @@ def test_patch_main_schema_path_without_attribute(): patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:core:2.0:User", value={ @@ -395,7 +373,7 @@ def test_patch_schema_path_with_invalid_value_type(): patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:core:2.0:User", value="invalid string value", @@ -427,7 +405,7 @@ def test_patch_delete_extension_root(): patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", ) diff --git a/tests/test_patch_op_remove.py b/tests/test_patch_op_remove.py index d1452c7..e932e74 100644 --- a/tests/test_patch_op_remove.py +++ b/tests/test_patch_op_remove.py @@ -13,7 +13,7 @@ def test_remove_operation_single_attribute(): """Test removing a single-valued attribute.""" user = User(nick_name="Babs") patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + operations=[PatchOperation[User](op=PatchOperation.Op.remove, path="nickName")] ) result = patch.patch(user) assert result is True @@ -24,7 +24,7 @@ def test_remove_operation_nonexistent_attribute(): """Test removing an attribute that doesn't exist should not raise an error.""" user = User() patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + operations=[PatchOperation[User](op=PatchOperation.Op.remove, path="nickName")] ) result = patch.patch(user) assert result is False @@ -38,7 +38,7 @@ def test_remove_operation_on_non_list_attribute(): # Try to remove specific value from a single-valued field patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="nickName", value="TestValue" ) ] @@ -54,7 +54,9 @@ def test_remove_operation_sub_attribute(): """Test removing a sub-attribute of a complex attribute.""" user = User(name={"familyName": "Jensen", "givenName": "Barbara"}) patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="name.familyName")] + operations=[ + PatchOperation[User](op=PatchOperation.Op.remove, path="name.familyName") + ] ) result = patch.patch(user) assert result is True @@ -66,7 +68,7 @@ def test_remove_operation_complex_attribute(): """Test removing an entire complex attribute.""" user = User(name={"familyName": "Jensen", "givenName": "Barbara"}) patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="name")] + operations=[PatchOperation[User](op=PatchOperation.Op.remove, path="name")] ) result = patch.patch(user) assert result is True @@ -77,7 +79,9 @@ def test_remove_operation_sub_attribute_parent_none(): """Test removing a sub-attribute when parent is None.""" user = User(name=None) patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="name.familyName")] + operations=[ + PatchOperation[User](op=PatchOperation.Op.remove, path="name.familyName") + ] ) result = patch.patch(user) assert result is False @@ -101,7 +105,7 @@ def test_remove_operation_multiple_attribute_all(): ] ) patch = PatchOp[Group]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="members")] + operations=[PatchOperation[Group](op=PatchOperation.Op.remove, path="members")] ) result = patch.patch(group) assert result is True @@ -118,7 +122,7 @@ def test_remove_operation_multiple_attribute_with_value(): ) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="emails", value={"value": "work@example.com", "type": "work"}, @@ -136,7 +140,7 @@ def test_remove_operation_with_value_not_in_list(): user = User(emails=[{"value": "test@example.com", "type": "work"}]) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="emails", value={"value": "other@example.com", "type": "work"}, @@ -159,7 +163,7 @@ def test_values_match_basemodel_second_parameter(): member_obj = GroupMember(value="123", display="Test User") # BaseModel patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.remove, path="members", value=member_obj, # BaseModel as second parameter @@ -182,7 +186,7 @@ def test_remove_operations_on_nonexistent_and_basemodel_values(): # Test removing non-existent value patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="emails", value={"value": "nonexistent@example.com", "type": "work"}, @@ -208,7 +212,7 @@ def test_complex_object_creation_and_basemodel_matching(): # Remove specific member by BaseModel value patch = PatchOp[Group]( operations=[ - PatchOperation( + PatchOperation[Group]( op=PatchOperation.Op.remove, path="members", value=GroupMember(value="123", display="Test User"), @@ -223,9 +227,8 @@ def test_complex_object_creation_and_basemodel_matching(): def test_remove_operation_bypass_validation_no_path(): - """Test remove operation with no path raises error during validation per RFC7644.""" - # Path validation now happens during model validation - with pytest.raises(ValidationError, match="path.*invalid"): + """Test remove operation with no path raises noTarget error per RFC7644 §3.5.2.2.""" + with pytest.raises(ValidationError, match="no match"): PatchOp.model_validate( { "operations": [ @@ -237,10 +240,10 @@ def test_remove_operation_bypass_validation_no_path(): def test_defensive_path_check_in_remove(): - """Test defensive path check in _apply_remove method.""" + """Test defensive path check in _apply_remove method per RFC7644 §3.5.2.2.""" user = User(nick_name="Test") patch = PatchOp[User]( - operations=[PatchOperation(op=PatchOperation.Op.remove, path="nickName")] + operations=[PatchOperation[User](op=PatchOperation.Op.remove, path="nickName")] ) # Force path to None to test defensive check @@ -248,42 +251,5 @@ def test_defensive_path_check_in_remove(): op=PatchOperation.Op.remove, path=None ) - with pytest.raises(ValueError, match="path.*invalid"): - patch.patch(user) - - -def test_remove_value_empty_attr_path(): - """Test _remove_value_at_path with empty attr_path after URN resolution (line 291).""" - user = User() - - # URN with trailing colon results in empty attr_path after parsing - patch = PatchOp[User]( - operations=[ - PatchOperation( - op=PatchOperation.Op.remove, - path="urn:ietf:params:scim:schemas:core:2.0:User:", - ) - ] - ) - - with pytest.raises(ValueError, match="path"): - patch.patch(user) - - -def test_remove_specific_value_empty_attr_path(): - """Test _remove_specific_value with empty attr_path after URN resolution (line 316).""" - user = User() - - # URN with trailing colon results in empty attr_path after parsing - patch = PatchOp[User]( - operations=[ - PatchOperation( - op=PatchOperation.Op.remove, - path="urn:ietf:params:scim:schemas:core:2.0:User:", - value={"some": "value"}, - ) - ] - ) - - with pytest.raises(ValueError, match="path"): + with pytest.raises(ValueError, match="no match"): patch.patch(user) diff --git a/tests/test_patch_op_replace.py b/tests/test_patch_op_replace.py index c666661..3a2a256 100644 --- a/tests/test_patch_op_replace.py +++ b/tests/test_patch_op_replace.py @@ -20,7 +20,7 @@ def test_replace_operation_single_attribute(): user = User(nick_name="OldNick") patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="nickName", value="NewNick" ) ] @@ -35,7 +35,7 @@ def test_replace_operation_single_attribute_none_to_value(): user = User(nick_name=None) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="nickName", value="NewNick" ) ] @@ -50,7 +50,7 @@ def test_replace_operation_nonexistent_attribute(): user = User() patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="nickName", value="NewNick" ) ] @@ -65,7 +65,9 @@ def test_replace_operation_same_value(): user = User(nick_name="Test") patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.replace_, path="nickName", value="Test") + PatchOperation[User]( + op=PatchOperation.Op.replace_, path="nickName", value="Test" + ) ] ) result = patch.patch(user) @@ -78,7 +80,7 @@ def test_replace_operation_sub_attribute(): user = User(name={"familyName": "OldName", "givenName": "Barbara"}) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="name.familyName", value="NewName" ) ] @@ -94,7 +96,7 @@ def test_replace_operation_complex_attribute(): user = User(name={"familyName": "OldName", "givenName": "Barbara"}) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="name", value={"familyName": "NewName", "givenName": "John"}, @@ -112,7 +114,7 @@ def test_replace_operation_sub_attribute_parent_none(): user = User(name=None) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="name.familyName", value="NewName" ) ] @@ -133,7 +135,7 @@ def test_replace_operation_multiple_attribute_all(): ) patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="emails", value=[{"value": "new@example.com", "type": "work"}], @@ -157,7 +159,7 @@ def test_replace_operation_no_path(): user = User(nick_name="OldNick", display_name="Old Display") patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, value={ "nickName": "NewNick", @@ -177,7 +179,7 @@ def test_replace_operation_no_path_same_attributes(): user = User(nick_name="Test", display_name="Display") patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, value={"nickName": "Test", "displayName": "Display"}, ) @@ -194,7 +196,7 @@ def test_replace_operation_with_non_dict_value_no_path(): user = User(nick_name="Test") patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.replace_, value="invalid_value") + PatchOperation[User](op=PatchOperation.Op.replace_, value="invalid_value") ] ) result = patch.patch(user) @@ -212,7 +214,7 @@ class Dummy(Resource): with pytest.raises(ValidationError, match="mutability"): PatchOp[Dummy]( operations=[ - PatchOperation( + PatchOperation[Dummy]( op=PatchOperation.Op.replace_, path="immutable", value="new_value" ) ] diff --git a/tests/test_patch_op_validation.py b/tests/test_patch_op_validation.py index 8ff3a3c..7f59994 100644 --- a/tests/test_patch_op_validation.py +++ b/tests/test_patch_op_validation.py @@ -11,6 +11,77 @@ from scim2_models.resources.resource import Resource +def test_patch_op_add_invalid_extension_path(): + user = User(user_name="john") + patch_op = PatchOp[User]( + operations=[ + PatchOperation[User]( + op="add", + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + value={"key": "value"}, + ) + ] + ) + with pytest.raises( + ValueError, + match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', + ): + patch_op.patch(user) + + +def test_patch_op_replace_invalid_extension_path(): + user = User(user_name="john") + patch_op = PatchOp[User]( + operations=[ + PatchOperation[User]( + op="replace", + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.attr", + value="test", + ) + ] + ) + with pytest.raises( + ValueError, + match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', + ): + patch_op.patch(user) + + +def test_patch_op_remove_invalid_extension_path(): + user = User(user_name="john") + patch_op = PatchOp[User]( + operations=[ + PatchOperation[User]( + op="remove", + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.attr", + ) + ] + ) + with pytest.raises( + ValueError, + match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', + ): + patch_op.patch(user) + + +def test_patch_op_remove_invalid_extension_path_with_value(): + user = User(user_name="john") + patch_op = PatchOp[User]( + operations=[ + PatchOperation[User]( + op="remove", + path="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User.attr", + value="some_value", + ) + ] + ) + with pytest.raises( + ValueError, + match=r'The "path" attribute was invalid or malformed \(see Figure 7 of RFC7644\)\.', + ): + patch_op.patch(user) + + def test_patch_op_without_type_parameter(): """Test that PatchOp cannot be instantiated without a type parameter.""" with pytest.raises(TypeError, match="PatchOp requires a type parameter"): @@ -59,11 +130,13 @@ def test_validate_patchop_case_insensitivity(): }, ) == PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="userName", value="Rivard" ), - PatchOperation(op=PatchOperation.Op.add, path="userName", value="Rivard"), - PatchOperation( + PatchOperation[User]( + op=PatchOperation.Op.add, path="userName", value="Rivard" + ), + PatchOperation[User]( op=PatchOperation.Op.remove, path="userName", value="Rivard" ), ] @@ -102,8 +175,8 @@ def test_path_required_for_remove_operations(): context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) - # Validation now happens during model validation - with pytest.raises(ValidationError, match="path.*invalid"): + # RFC 7644 §3.5.2.2: remove without path returns noTarget error + with pytest.raises(ValidationError, match="no match"): PatchOp[User].model_validate( { "operations": [ @@ -160,8 +233,8 @@ def test_patch_operation_validation_contexts(): context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) - # Validation for missing path in remove operations now happens during model validation - with pytest.raises(ValidationError, match="path.*invalid"): + # RFC 7644 §3.5.2.2: remove without path returns noTarget error + with pytest.raises(ValidationError, match="no match"): PatchOperation.model_validate( {"op": "remove"}, context={"scim": Context.RESOURCE_PATCH_REQUEST}, @@ -173,16 +246,8 @@ def test_patch_operation_validation_contexts(): context={"scim": Context.RESOURCE_PATCH_REQUEST}, ) - operation1 = PatchOperation.model_validate( - {"op": "add", "path": " ", "value": "test"} - ) - assert operation1.path == " " - - operation2 = PatchOperation.model_validate({"op": "remove"}) - assert operation2.path is None - - operation3 = PatchOperation.model_validate({"op": "add", "path": "test"}) - assert operation3.value is None + operation = PatchOperation.model_validate({"op": "remove"}) + assert operation.path is None def test_validate_mutability_readonly_error(): @@ -303,7 +368,7 @@ def test_patch_operation_with_schema_only_urn_path(): # This URN resolves to just the schema without an attribute name patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="urn:ietf:params:scim:schemas:core:2.0:User", value="test", @@ -335,21 +400,6 @@ def test_add_remove_operations_on_group_members_allowed(): assert len(patch_op.operations) == 2 -def test_patch_error_handling_invalid_path(): - """Test error handling for invalid patch paths.""" - user = User(user_name="test") - - # Test with invalid path format - patch = PatchOp[User]( - operations=[ - PatchOperation(op=PatchOperation.Op.add, path="invalid..path", value="test") - ] - ) - - with pytest.raises(ValueError): - patch.patch(user) - - def test_patch_error_handling_no_operations(): """Test patch behavior with no operations (using model_construct to bypass validation).""" user = User(user_name="test") @@ -367,7 +417,7 @@ def test_patch_error_handling_type_mismatch(): # Try to set active (boolean) to a string patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.replace_, path="active", value="not_a_boolean" ) ] @@ -417,13 +467,12 @@ def test_patch_op_with_typevar_bound_to_non_resource(): def test_create_parent_object_return_none(): """Test _create_parent_object returns None when type resolution fails.""" - # This test uses a TypeVar to trigger the case where get_field_root_type returns None user = User() # Create a patch that will trigger _create_parent_object with complex path patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.add, path="complexField.subField", # Non-existent complex field value="test", @@ -431,8 +480,8 @@ def test_create_parent_object_return_none(): ] ) - # This should raise ValueError because field doesn't exist - with pytest.raises(ValueError, match="no match|did not yield"): + # Non-existent field returns invalidPath error + with pytest.raises(ValueError, match="path.*invalid"): patch.patch(user) @@ -451,7 +500,9 @@ def test_patch_error_handling_invalid_operation(): user = User(user_name="test") patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.add, path="nickName", value="test") + PatchOperation[User]( + op=PatchOperation.Op.add, path="nickName", value="test" + ) ] ) @@ -469,12 +520,14 @@ def test_remove_value_at_path_invalid_field(): # Create patch that attempts to remove from invalid parent field patch = PatchOp[User]( operations=[ - PatchOperation(op=PatchOperation.Op.remove, path="invalidParent.subField") + PatchOperation[User]( + op=PatchOperation.Op.remove, path="invalidParent.subField" + ) ] ) - # This should raise ValueError for invalid field name - with pytest.raises(ValueError, match="no match|did not yield"): + # Non-existent field returns invalidPath error + with pytest.raises(ValueError, match="path.*invalid"): patch.patch(user) @@ -485,7 +538,7 @@ def test_remove_specific_value_invalid_field(): # Create patch that attempts to remove specific value from invalid field patch = PatchOp[User]( operations=[ - PatchOperation( + PatchOperation[User]( op=PatchOperation.Op.remove, path="invalidField", value={"some": "value"}, @@ -493,8 +546,8 @@ def test_remove_specific_value_invalid_field(): ] ) - # This should raise ValueError for invalid field name - with pytest.raises(ValueError, match="no match|did not yield"): + # Non-existent field returns invalidPath error + with pytest.raises(ValueError, match="path.*invalid"): patch.patch(user) diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..d2e433e --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,1419 @@ +"""Tests for SCIM path validation utilities.""" + +from typing import Any + +import pydantic +import pytest + +from scim2_models import Email +from scim2_models import EnterpriseUser +from scim2_models import Group +from scim2_models import Manager +from scim2_models import Mutability +from scim2_models import Name +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(): + """Test that valid SCIM paths are accepted.""" + valid_paths = [ + "userName", + "name.familyName", + "emails.value", + "groups.display", + "urn:ietf:params:scim:schemas:core:2.0:User:userName", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + 'emails[type eq "work"].value', + 'groups[display eq "Admin"]', + "meta.lastModified", + "a", # Single character + "a.b", # Simple dotted + "a_b", # Underscore + "a-b", # Hyphen + ] + + for path in valid_paths: + Path(path) + + +def test_validate_scim_path_syntax_invalid_paths(): + """Test that invalid SCIM paths are rejected.""" + invalid_paths = [ + " ", # Whitespace only + "123invalid", # Starts with digit + "invalid..path", # Double dots + "invalid@path", # Invalid character + "urn:invalid", # Invalid URN format + "urn:too:short", # URN too short + "urn:ending:with:a:comma:", # URN ending with a comma + ] + + for path in invalid_paths: + with pytest.raises(ValueError): + Path(path) + + +def test_empty_path_is_valid(): + """Empty path represents the resource root.""" + path = Path("") + assert str(path) == "" + + +def test_path_generic_type_is_accepted(): + """Path with or without type parameter accepts any valid syntax.""" + + class ModelWithType(BaseModel): + path: Path[User] + + class ModelWithoutType(BaseModel): + path: Path + + ModelWithType.model_validate({"path": "userName"}) + ModelWithType.model_validate({"path": "anyAttribute"}) + ModelWithoutType.model_validate({"path": "anyAttribute"}) + + +def test_path_validation_from_path_instance(): + """Path field accepts another Path instance.""" + + class ModelWithPath(BaseModel): + path: Path[User] + + source_path = Path("userName") + model = ModelWithPath.model_validate({"path": source_path}) + assert str(model.path) == "userName" + + +def test_path_validation_rejects_invalid_type(): + """Path field rejects non-string non-Path values.""" + + class ModelWithPath(BaseModel): + path: Path[User] + + with pytest.raises(pydantic.ValidationError): + ModelWithPath.model_validate({"path": 123}) + + +# --- Path bound to model tests --- + + +def test_model_simple_attribute(): + """Model property returns the target model for simple attribute.""" + path = Path[User]("userName") + assert path.model == User + + +def test_model_complex_attribute(): + """Model property returns the nested model for complex attribute.""" + path = Path[User]("name.familyName") + assert path.model == Name + + +def test_model_extension_attribute(): + """Model property returns extension model for extension path.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.model == EnterpriseUser + + +def test_model_extension_complex_attribute(): + """Model property navigates into extension complex attributes.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value" + ) + assert path.model == Manager + + +def test_model_invalid_attribute(): + """Model property returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.model is None + + +def test_model_unbound_path(): + """Model property returns None for unbound path.""" + path = Path("userName") + assert path.model is None + + +def test_field_name_simple_attribute(): + """field_name property returns snake_case field name.""" + path = Path[User]("userName") + assert path.field_name == "user_name" + + +def test_field_name_complex_attribute(): + """field_name property returns snake_case for nested attribute.""" + path = Path[User]("name.familyName") + assert path.field_name == "family_name" + + +def test_field_name_extension_attribute(): + """field_name property works for extension attributes.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.field_name == "employee_number" + + +def test_field_name_invalid_attribute(): + """field_name property returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.field_name is None + + +def test_field_name_unbound_path(): + """field_name property returns None for unbound path.""" + path = Path("userName") + assert path.field_name is None + + +def test_field_name_schema_only(): + """field_name property returns None for schema-only path.""" + path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") + assert path.field_name is None + + +def test_urn_simple_attribute(): + """URN property returns fully qualified URN.""" + path = Path[User]("userName") + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:userName" + + +def test_urn_complex_attribute(): + """URN property includes dotted path.""" + path = Path[User]("name.familyName") + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName" + + +def test_urn_already_qualified(): + """URN property preserves already qualified paths.""" + path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:userName" + + +def test_urn_extension_attribute(): + """URN property works for extension attributes.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert ( + path.urn + == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + + +def test_urn_invalid_attribute(): + """URN property returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.urn is None + + +def test_urn_unbound_path(): + """URN property returns None for unbound path.""" + path = Path("userName") + assert path.urn is None + + +def test_urn_schema_only(): + """URN property returns schema for schema-only path.""" + path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User" + + +def test_path_caching(): + """Path[Model] classes are cached.""" + path_class_1 = Path[User] + path_class_2 = Path[User] + assert path_class_1 is path_class_2 + + +def test_path_different_models(): + """Different models create different Path classes.""" + path_class_user = Path[User] + path_class_group = Path[Group] + assert path_class_user is not path_class_group + + +# --- Path.get() tests --- + + +def test_get_simple_attribute(): + """Get a simple attribute value.""" + user = User(user_name="john.doe") + path = Path("userName") + assert path.get(user) == "john.doe" + + +def test_get_complex_attribute(): + """Get a complex attribute sub-value.""" + user = User(user_name="john", name=Name(family_name="Doe", given_name="John")) + path = Path("name.familyName") + assert path.get(user) == "Doe" + + +def test_get_with_schema_urn_prefix(): + """Get value using full schema URN prefix.""" + user = User(user_name="john.doe") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.get(user) == "john.doe" + + +def test_get_extension_attribute(): + """Get an extension attribute value.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.get(user) == "12345" + + +def test_get_extension_root(): + """Get the entire extension object.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") + result = path.get(user) + assert isinstance(result, EnterpriseUser) + assert result.employee_number == "12345" + + +def test_get_none_when_attribute_missing(): + """Get returns None when attribute is not set.""" + user = User(user_name="john") + path = Path("displayName") + assert path.get(user) is None + + +def test_get_none_when_parent_missing(): + """Get returns None when parent complex attribute is not set.""" + user = User(user_name="john") + path = Path("name.familyName") + assert path.get(user) is None + + +def test_get_invalid_attribute(): + """Get raises PathNotFoundError for invalid attribute.""" + user = User(user_name="john") + path = Path("invalidAttribute") + with pytest.raises(PathNotFoundError): + path.get(user) + + +def test_get_extension_complex_subattribute(): + """Get a sub-attribute from extension complex attribute.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser( + manager=Manager(value="mgr-123", display_name="Boss") + ) + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value" + ) + assert path.get(user) == "mgr-123" + + +def test_get_invalid_first_part_in_complex_path(): + """Get raises PathNotFoundError 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): + path.get(user) + + +def test_get_main_schema_prefix_strips_correctly(): + """Get value with main schema prefix handles stripping.""" + user = User(user_name="john", display_name="John Doe") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:displayName") + assert path.get(user) == "John Doe" + + +def test_get_extension_attribute_uppercase_urn(): + """Get extension attribute with uppercase URN.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path( + "URN:IETF:PARAMS:SCIM:SCHEMAS:EXTENSION:ENTERPRISE:2.0:USER:employeeNumber" + ) + assert path.get(user) == "12345" + + +# --- Path.set() tests --- + + +def test_set_simple_attribute(): + """Set a simple attribute value.""" + user = User(user_name="john") + path = Path("displayName") + result = path.set(user, "John Doe") + assert result is True + assert user.display_name == "John Doe" + + +def test_set_complex_attribute(): + """Set a complex attribute sub-value, creating parent if needed.""" + user = User(user_name="john") + path = Path("name.familyName") + result = path.set(user, "Doe") + assert result is True + assert user.name.family_name == "Doe" + + +def test_set_with_schema_urn_prefix(): + """Set value using full schema URN prefix.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:displayName") + result = path.set(user, "John Doe") + assert result is True + assert user.display_name == "John Doe" + + +def test_set_extension_attribute(): + """Set an extension attribute value, creating extension if needed.""" + user = User[EnterpriseUser](user_name="john") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + result = path.set(user, "12345") + assert result is True + assert user[EnterpriseUser].employee_number == "12345" + + +def test_set_extension_root(): + """Set the entire extension object.""" + user = User[EnterpriseUser](user_name="john") + path = Path("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") + result = path.set(user, EnterpriseUser(employee_number="99999")) + assert result is True + assert user[EnterpriseUser].employee_number == "99999" + + +def test_set_extension_attribute_uppercase_urn(): + """Set extension attribute with uppercase URN.""" + user = User[EnterpriseUser](user_name="john") + path = Path( + "URN:IETF:PARAMS:SCIM:SCHEMAS:EXTENSION:ENTERPRISE:2.0:USER:employeeNumber" + ) + result = path.set(user, "12345") + assert result is True + assert user[EnterpriseUser].employee_number == "12345" + + +def test_set_multivalued_wraps_single_value(): + """Set wraps single value in list for multi-valued attributes.""" + user = User(user_name="john") + path = Path("emails") + email = Email(value="john@example.com") + result = path.set(user, email) + assert result is True + assert user.emails == [email] + + +def test_set_invalid_attribute(): + """Set raises PathNotFoundError for invalid attribute.""" + user = User(user_name="john") + path = Path("invalidAttribute") + with pytest.raises(PathNotFoundError): + path.set(user, "value") + + +def test_set_extension_complex_subattribute(): + """Set a sub-attribute on extension complex attribute.""" + user = User[EnterpriseUser](user_name="john") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value" + ) + result = path.set(user, "mgr-456") + assert result is True + assert user[EnterpriseUser].manager.value == "mgr-456" + + +def test_set_invalid_first_part_in_complex_path(): + """Set raises PathNotFoundError when first part of complex path is invalid.""" + user = User(user_name="john") + path = Path("invalidAttr.subField") + with pytest.raises(PathNotFoundError): + path.set(user, "value") + + +def test_set_main_schema_prefix_strips_correctly(): + """Set value with main schema prefix handles stripping.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:displayName") + result = path.set(user, "John Doe") + assert result is True + assert user.display_name == "John Doe" + + +def test_set_creates_intermediate_objects(): + """Set creates intermediate complex objects when needed.""" + user = User(user_name="john") + assert user.name is None + path = Path("name.givenName") + result = path.set(user, "John") + assert result is True + assert user.name.given_name == "John" + + +def test_set_schema_only_path_merges_dict(): + """Set with schema-only path merges dict into resource.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + result = path.set(user, {"displayName": "Test"}) + assert result is True + assert user.display_name == "Test" + + +def test_set_cannot_navigate_into_list(): + """Set returns False when trying to navigate into a multi-valued attribute.""" + user = User(user_name="john", emails=[Email(value="john@example.com")]) + path = Path("emails.value") + result = path.set(user, "new@example.com") + assert result is False + + +def test_set_invalid_last_part_in_complex_path(): + """Set raises PathNotFoundError 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): + path.set(user, "value") + + +# --- Path.iter_paths() tests --- + + +def test_iter_paths_simple_attributes(): + """Iterate over simple attributes of a model.""" + paths = list(Path[User].iter_paths(include_subattributes=False)) + path_strings = [str(p) for p in paths] + + assert "userName" in path_strings + assert "displayName" in path_strings + assert "name" in path_strings + assert "emails" in path_strings + + +def test_iter_paths_with_subattributes(): + """Iterate includes sub-attributes when enabled.""" + paths = list(Path[User].iter_paths(include_subattributes=True)) + path_strings = [str(p) for p in paths] + + assert "name" in path_strings + assert "name.familyName" in path_strings + assert "name.givenName" in path_strings + assert "emails" in path_strings + assert "emails.value" in path_strings + assert "emails.type" in path_strings + + +def test_iter_paths_excludes_meta_id_schemas(): + """Iterate excludes meta, id, and schemas fields.""" + paths = list(Path[User].iter_paths()) + path_strings = [str(p) for p in paths] + + assert "meta" not in path_strings + assert "id" not in path_strings + assert "schemas" not in path_strings + + +def test_iter_paths_includes_extensions(): + """Iterate includes extension model attributes.""" + paths = list(Path[User[EnterpriseUser]].iter_paths(include_subattributes=False)) + path_strings = [str(p) for p in paths] + + assert "userName" in path_strings + assert ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + in path_strings + ) + assert ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department" + in path_strings + ) + + +def test_iter_paths_excludes_extensions(): + """Iterate excludes extension attributes when include_extensions=False.""" + paths = list( + Path[User[EnterpriseUser]].iter_paths( + include_subattributes=False, include_extensions=False + ) + ) + path_strings = [str(p) for p in paths] + + assert "userName" in path_strings + assert not any("enterprise" in p.lower() for p in path_strings) + + +def test_iter_paths_returns_bound_paths(): + """Iterate returns paths bound to the model.""" + paths = list(Path[User[EnterpriseUser]].iter_paths(include_subattributes=False)) + + user_name_path = next(p for p in paths if str(p) == "userName") + assert user_name_path.model == User[EnterpriseUser] + + ext_path = next( + p + for p in paths + if str(p) + == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert ext_path.model == EnterpriseUser + + +def test_iter_paths_without_extensions(): + """Iterate over model without extensions.""" + paths = list(Path[User].iter_paths(include_subattributes=False)) + path_strings = [str(p) for p in paths] + + assert "userName" in path_strings + assert "displayName" in path_strings + assert not any("enterprise" in p.lower() for p in path_strings) + + +def test_iter_paths_requires_bound_path(): + """Iterate raises TypeError if Path is not bound to a model.""" + with pytest.raises(TypeError, match="iter_paths requires a bound Path type"): + list(Path.iter_paths()) + + +# --- Path.set() with is_add=True tests --- + + +def test_set_add_to_empty_list(): + """Add a value to an empty multi-valued attribute.""" + user = User(user_name="john") + email = Email(value="john@example.com") + path = Path("emails") + result = path.set(user, email, is_add=True) + assert result is True + assert user.emails == [email] + + +def test_set_add_to_existing_list(): + """Add a value to an existing multi-valued attribute.""" + email1 = Email(value="john@example.com") + user = User(user_name="john", emails=[email1]) + email2 = Email(value="john@work.com") + path = Path("emails") + result = path.set(user, email2, is_add=True) + assert result is True + assert len(user.emails) == 2 + assert email1 in user.emails + assert email2 in user.emails + + +def test_set_add_duplicate_not_added(): + """Adding a duplicate value returns False.""" + email = Email(value="john@example.com") + user = User(user_name="john", emails=[email]) + path = Path("emails") + result = path.set(user, Email(value="john@example.com"), is_add=True) + assert result is False + assert len(user.emails) == 1 + + +def test_set_add_multiple_values(): + """Add multiple values at once.""" + user = User(user_name="john") + emails = [Email(value="a@example.com"), Email(value="b@example.com")] + path = Path("emails") + result = path.set(user, emails, is_add=True) + assert result is True + assert len(user.emails) == 2 + + +def test_set_add_extension_attribute(): + """Add value to extension attribute with is_add (non-list, behaves like replace).""" + user = User[EnterpriseUser](user_name="john") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + result = path.set(user, "12345", is_add=True) + assert result is True + assert user[EnterpriseUser].employee_number == "12345" + + +# --- Path.delete() tests --- + + +def test_delete_simple_attribute(): + """Delete a simple attribute value.""" + user = User(user_name="john", display_name="John Doe") + path = Path("displayName") + result = path.delete(user) + assert result is True + assert user.display_name is None + + +def test_delete_attribute_already_none(): + """Delete returns False when attribute is already None.""" + user = User(user_name="john") + path = Path("displayName") + result = path.delete(user) + assert result is False + + +def test_delete_from_list_specific_value(): + """Delete a specific value from a multi-valued attribute.""" + email1 = Email(value="john@example.com") + email2 = Email(value="john@work.com") + user = User(user_name="john", emails=[email1, email2]) + path = Path("emails") + result = path.delete(user, Email(value="john@example.com")) + assert result is True + assert len(user.emails) == 1 + assert user.emails[0].value == "john@work.com" + + +def test_delete_from_list_value_not_found(): + """Delete returns False when value not in list.""" + email = Email(value="john@example.com") + user = User(user_name="john", emails=[email]) + path = Path("emails") + result = path.delete(user, Email(value="other@example.com")) + assert result is False + assert len(user.emails) == 1 + + +def test_delete_last_item_from_list(): + """Delete last item from list sets attribute to None.""" + email = Email(value="john@example.com") + user = User(user_name="john", emails=[email]) + path = Path("emails") + result = path.delete(user, email) + assert result is True + assert user.emails is None + + +def test_delete_extension_attribute(): + """Delete an extension attribute.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + result = path.delete(user) + assert result is True + assert user[EnterpriseUser].employee_number is None + + +def test_delete_extension_root(): + """Delete entire extension.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") + result = path.delete(user) + assert result is True + assert user[EnterpriseUser] is None + + +def test_delete_extension_attribute_uppercase_urn(): + """Delete extension attribute with uppercase URN.""" + user = User[EnterpriseUser](user_name="john") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + path = Path( + "URN:IETF:PARAMS:SCIM:SCHEMAS:EXTENSION:ENTERPRISE:2.0:USER:employeeNumber" + ) + result = path.delete(user) + assert result is True + assert user[EnterpriseUser].employee_number is None + + +def test_delete_complex_subattribute(): + """Delete a sub-attribute of a complex attribute.""" + user = User(user_name="john", name=Name(family_name="Doe", given_name="John")) + path = Path("name.familyName") + result = path.delete(user) + assert result is True + assert user.name.family_name is None + assert user.name.given_name == "John" + + +def test_delete_invalid_path(): + """Delete raises PathNotFoundError for invalid path.""" + user = User(user_name="john") + path = Path("invalidAttribute") + with pytest.raises(PathNotFoundError): + path.delete(user) + + +# --- Path component properties (schema, attr, parts) tests --- + + +def test_schema_simple_path(): + """Simple path has no schema.""" + path = Path("userName") + assert path.schema is None + + +def test_schema_dotted_path(): + """Dotted path without URN has no schema.""" + path = Path("name.familyName") + assert path.schema is None + + +def test_schema_urn_path(): + """URN path returns schema portion.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.schema == "urn:ietf:params:scim:schemas:core:2.0:User" + + +def test_schema_extension_path(): + """Extension path returns extension schema.""" + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.schema == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + + +def test_schema_schema_only_path(): + """Schema-only path returns the full schema.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + assert path.schema == "urn:ietf:params:scim:schemas:core:2.0" + + +def test_attr_simple_path(): + """Simple path attr is the path itself.""" + path = Path("userName") + assert path.attr == "userName" + + +def test_attr_dotted_path(): + """Dotted path attr is the full dotted path.""" + path = Path("name.familyName") + assert path.attr == "name.familyName" + + +def test_attr_urn_path(): + """URN path attr is the attribute after the schema.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.attr == "userName" + + +def test_attr_urn_dotted_path(): + """URN path with dotted attribute.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:name.familyName") + assert path.attr == "name.familyName" + + +def test_attr_schema_only_path(): + """Schema-only path has empty attr.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + assert path.attr == "User" + + +def test_attr_empty_path(): + """Empty path has empty attr.""" + path = Path("") + assert path.attr == "" + + +def test_parts_simple_path(): + """Simple path has single part.""" + path = Path("userName") + assert path.parts == ("userName",) + + +def test_parts_dotted_path(): + """Dotted path splits into parts.""" + path = Path("name.familyName") + assert path.parts == ("name", "familyName") + + +def test_parts_urn_path(): + """URN path parts are from attr portion only.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.parts == ("userName",) + + +def test_parts_urn_dotted_path(): + """URN path with dotted attribute splits correctly.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:name.familyName") + assert path.parts == ("name", "familyName") + + +def test_parts_empty_path(): + """Empty path has no parts.""" + path = Path("") + assert path.parts == () + + +def test_parts_deeply_nested(): + """Deeply nested path splits all parts.""" + path = Path("a.b.c.d") + assert path.parts == ("a", "b", "c", "d") + + +# --- Path prefix methods (is_prefix_of, has_prefix) tests --- + + +def test_is_prefix_of_simple_dot(): + """Path is prefix of dotted sub-path.""" + path = Path("emails") + assert path.is_prefix_of("emails.value") is True + + +def test_is_prefix_of_not_equal(): + """Equal paths are not prefixes of each other.""" + path = Path("emails") + assert path.is_prefix_of("emails") is False + + +def test_is_prefix_of_not_prefix(): + """Unrelated paths are not prefixes.""" + path = Path("emails") + assert path.is_prefix_of("userName") is False + + +def test_is_prefix_of_partial_match_not_prefix(): + """Partial string match without separator is not a prefix.""" + path = Path("email") + assert path.is_prefix_of("emails") is False + + +def test_is_prefix_of_schema_to_attribute(): + """Schema URN is prefix of full attribute path.""" + schema = Path("urn:ietf:params:scim:schemas:core:2.0:User") + assert ( + schema.is_prefix_of("urn:ietf:params:scim:schemas:core:2.0:User:userName") + is True + ) + + +def test_is_prefix_of_case_insensitive(): + """Prefix matching is case-insensitive.""" + path = Path("Emails") + assert path.is_prefix_of("emails.value") is True + + +def test_is_prefix_of_accepts_path_object(): + """is_prefix_of accepts Path objects.""" + path1 = Path("emails") + path2 = Path("emails.value") + assert path1.is_prefix_of(path2) is True + + +def test_has_prefix_simple_dot(): + """Path has prefix when it starts with prefix + dot.""" + path = Path("emails.value") + assert path.has_prefix("emails") is True + + +def test_has_prefix_not_equal(): + """Equal paths don't have each other as prefix.""" + path = Path("emails") + assert path.has_prefix("emails") is False + + +def test_has_prefix_not_prefix(): + """Unrelated paths don't have prefix relationship.""" + path = Path("userName") + assert path.has_prefix("emails") is False + + +def test_has_prefix_partial_match_not_prefix(): + """Partial string match without separator is not a prefix.""" + path = Path("emails") + assert path.has_prefix("email") is False + + +def test_has_prefix_schema_prefix(): + """Full path has schema as prefix.""" + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.has_prefix("urn:ietf:params:scim:schemas:core:2.0:User") is True + + +def test_has_prefix_case_insensitive(): + """Prefix matching is case-insensitive.""" + path = Path("emails.value") + assert path.has_prefix("EMAILS") is True + + +def test_has_prefix_accepts_path_object(): + """has_prefix accepts Path objects.""" + path1 = Path("emails.value") + path2 = Path("emails") + assert path1.has_prefix(path2) is True + + +def test_prefix_symmetry(): + """is_prefix_of and has_prefix are symmetric.""" + parent = Path("emails") + child = Path("emails.value") + assert parent.is_prefix_of(child) is True + assert child.has_prefix(parent) is True + assert child.is_prefix_of(parent) is False + assert parent.has_prefix(child) is False + + +# --- Bound path resolution edge cases --- + + +def test_model_extension_schema_only_path(): + """Model property returns extension for schema-only extension path.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ) + assert path.model == EnterpriseUser + assert path.field_name is None + + +def test_model_urn_unknown_extension(): + """Model property returns None for URN not matching any extension.""" + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:unknown:2.0:User:field" + ) + assert path.model is None + + +def test_model_dotted_path_invalid_intermediate(): + """Model property returns None when intermediate part is invalid.""" + path = Path[User]("invalidAttr.familyName") + assert path.model is None + + +def test_model_dotted_path_intermediate_not_complex(): + """Model property returns None when intermediate type is not a complex attribute.""" + path = Path[User]("userName.subField") + assert path.model is None + + +def test_urn_empty_path_non_resource(): + """URN property handles empty path on non-Resource model.""" + path = Path[Name]("") + assert path.urn is None + + +# --- get() edge cases --- + + +def test_get_extension_attribute_when_extension_is_none(): + """Get returns None when extension object doesn't exist.""" + user = User[EnterpriseUser](user_name="john") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.get(user) is None + + +def test_get_unknown_extension_raises(): + """Get raises InvalidPathError 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): + path.get(user) + + +def test_get_strict_false_returns_none_on_invalid_path(): + """Get with strict=False returns None instead of raising.""" + user = User(user_name="john") + path = Path("invalidAttribute") + assert path.get(user, strict=False) is None + + +def test_get_urn_on_extension_instance(): + """Get with URN path on Extension instance (not Resource).""" + ext = EnterpriseUser(employee_number="12345") + path = Path( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ) + assert path.get(ext) == "12345" + + +def test_get_mismatched_urn_on_extension_instance(): + """Get with mismatched URN on Extension returns None.""" + ext = EnterpriseUser(employee_number="12345") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.get(ext) is None + + +# --- set() edge cases --- + + +def test_set_explicit_schema_path_with_non_dict_raises(): + """Set with explicit schema path and non-dict value raises InvalidPathError.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + with pytest.raises(InvalidPathError): + path.set(user, "not a dict") + + +def test_set_schema_path_with_all_invalid_fields(): + """Set with schema path where all fields are invalid returns False.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + result = path.set(user, {"invalidField1": "a", "invalidField2": "b"}) + assert result is False + + +def test_set_schema_path_with_unchanged_value(): + """Set with schema path where value equals existing returns False.""" + user = User(user_name="john", display_name="John") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + result = path.set(user, {"displayName": "John"}) + assert result is False + + +def test_set_strict_false_returns_false_on_invalid_path(): + """Set with strict=False returns False instead of raising.""" + user = User(user_name="john") + path = Path("invalidAttribute") + result = path.set(user, "value", strict=False) + assert result is False + + +def test_set_add_list_all_duplicates(): + """Set with is_add=True returns False when all list values are duplicates.""" + email = Email(value="john@example.com") + user = User(user_name="john", emails=[email]) + path = Path("emails") + result = path.set( + user, + [Email(value="john@example.com")], + is_add=True, + ) + assert result is False + assert len(user.emails) == 1 + + +def test_set_unchanged_value(): + """Set returns False when new value equals existing value.""" + user = User(user_name="john", display_name="John Doe") + path = Path("displayName") + result = path.set(user, "John Doe") + assert result is False + + +# --- delete() edge cases --- + + +def test_delete_strict_false_returns_false_on_invalid_path(): + """Delete with strict=False returns False instead of raising.""" + user = User(user_name="john") + path = Path("invalidAttribute") + result = path.delete(user, strict=False) + assert result is False + + +def test_delete_from_non_list_with_value(): + """Delete with value parameter on non-list attribute returns False.""" + user = User(user_name="john", display_name="John Doe") + path = Path("displayName") + result = path.delete(user, "John Doe") + assert result is False + assert user.display_name == "John Doe" + + +def test_delete_extension_already_none(): + """Delete extension that is already None returns False.""" + user = User[EnterpriseUser](user_name="john") + path = Path("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User") + result = path.delete(user) + assert result is False + + +def test_model_dotted_path_invalid_last_part(): + """Model property returns None when last part of dotted path is invalid.""" + path = Path[User]("name.invalidField") + assert path.model is None + + +def test_get_schema_only_path_returns_resource(): + """Get with schema-only path returns the resource itself.""" + user = User(user_name="john", display_name="John") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + result = path.get(user) + assert result is user + + +def test_set_on_extension_with_mismatched_urn(): + """Set on Extension instance with mismatched URN returns False.""" + ext = EnterpriseUser(employee_number="12345") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + result = path.set(ext, "newValue") + assert result is False + + +def test_set_empty_path_non_dict_value(): + """Set with empty path and non-dict value returns False.""" + user = User(user_name="john") + path = Path("") + result = path.set(user, "not a dict") + assert result is False + + +def test_set_dotted_path_intermediate_type_none(): + """Set returns False when intermediate field has no determinable type.""" + + class TestResource(User): + untyped: Any = None + + resource = TestResource(user_name="john") + path = Path("untyped.sub") + result = path.set(resource, "value") + assert result is False + + +def test_delete_on_extension_with_mismatched_urn(): + """Delete on Extension instance with mismatched URN returns False.""" + ext = EnterpriseUser(employee_number="12345") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User:userName") + result = path.delete(ext) + assert result is False + + +def test_delete_schema_only_path_raises(): + """Delete with schema-only path raises InvalidPathError.""" + user = User(user_name="john") + path = Path("urn:ietf:params:scim:schemas:core:2.0:User") + with pytest.raises(InvalidPathError): + path.delete(user) + + +def test_delete_dotted_path_intermediate_none(): + """Delete returns False when intermediate object in path is None.""" + user = User(user_name="john") + assert user.name is None + path = Path("name.familyName") + result = path.delete(user) + assert result is False + + +def test_model_extension_bound_with_mismatched_urn(): + """Model property on Extension-bound path with unrelated URN.""" + path = Path[EnterpriseUser]("urn:ietf:params:scim:schemas:core:2.0:User:userName") + assert path.model is None + + +def test_iter_paths_on_extension(): + """Iterate paths on an Extension model (not a Resource).""" + paths = list(Path[EnterpriseUser].iter_paths(include_subattributes=False)) + path_strings = [str(p) for p in paths] + + assert ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + in path_strings + ) + assert ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department" + in path_strings + ) + + +# --- field_type property tests --- + + +def test_field_type_simple_attribute(): + """field_type returns the Python type for simple attribute.""" + path = Path[User]("userName") + assert path.field_type is str + + +def test_field_type_complex_attribute(): + """field_type returns the complex attribute class.""" + path = Path[User]("name") + assert path.field_type is Name + + +def test_field_type_multivalued_attribute(): + """field_type returns the element type for multi-valued attributes.""" + path = Path[User]("emails") + assert path.field_type is Email + + +def test_field_type_nested_attribute(): + """field_type returns the type for nested attribute.""" + path = Path[User]("name.familyName") + assert path.field_type is str + + +def test_field_type_invalid_attribute(): + """field_type returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.field_type is None + + +def test_field_type_unbound_path(): + """field_type returns None for unbound path.""" + path = Path("userName") + assert path.field_type is None + + +def test_field_type_schema_only_path(): + """field_type returns None for schema-only path.""" + path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") + assert path.field_type is None + + +# --- is_multivalued property tests --- + + +def test_is_multivalued_true(): + """is_multivalued returns True for multi-valued attribute.""" + path = Path[User]("emails") + assert path.is_multivalued is True + + +def test_is_multivalued_false(): + """is_multivalued returns False for single-valued attribute.""" + path = Path[User]("userName") + assert path.is_multivalued is False + + +def test_is_multivalued_nested_attribute(): + """is_multivalued works for nested attributes.""" + path = Path[User]("name.familyName") + assert path.is_multivalued is False + + +def test_is_multivalued_invalid_attribute(): + """is_multivalued returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.is_multivalued is None + + +def test_is_multivalued_unbound_path(): + """is_multivalued returns None for unbound path.""" + path = Path("emails") + assert path.is_multivalued is None + + +# --- get_annotation() method tests --- + + +def test_get_annotation_required(): + """get_annotation returns Required annotation value.""" + path = Path[User]("userName") + assert path.get_annotation(Required) == Required.true + + +def test_get_annotation_mutability(): + """get_annotation returns Mutability annotation value.""" + path = Path[User]("userName") + assert path.get_annotation(Mutability) == Mutability.read_write + + +def test_get_annotation_nested_attribute(): + """get_annotation works for nested attributes.""" + path = Path[User]("name.familyName") + result = path.get_annotation(Required) + assert result is None or isinstance(result, Required) + + +def test_get_annotation_invalid_attribute(): + """get_annotation returns None for invalid attribute.""" + path = Path[User]("invalidAttribute") + assert path.get_annotation(Required) is None + + +def test_get_annotation_unbound_path(): + """get_annotation returns None for unbound path.""" + path = Path("userName") + assert path.get_annotation(Required) is None + + +# --- iter_paths() with filters tests --- + + +def test_iter_paths_filter_by_required(): + """iter_paths with required filter only yields matching paths.""" + paths = list( + Path[User].iter_paths(include_subattributes=False, required=[Required.true]) + ) + path_strings = [str(p) for p in paths] + + assert "userName" in path_strings + for path in paths: + assert path.get_annotation(Required) == Required.true + + +def test_iter_paths_filter_by_mutability(): + """iter_paths with mutability filter only yields matching paths.""" + paths = list( + Path[User].iter_paths( + include_subattributes=False, mutability=[Mutability.read_only] + ) + ) + + for path in paths: + assert path.get_annotation(Mutability) == Mutability.read_only + + +def test_iter_paths_filter_by_required_and_mutability(): + """iter_paths with both filters applies both.""" + paths = list( + Path[User].iter_paths( + include_subattributes=False, + required=[Required.true], + mutability=[Mutability.read_write], + ) + ) + + for path in paths: + assert path.get_annotation(Required) == Required.true + assert path.get_annotation(Mutability) == Mutability.read_write + + +def test_iter_paths_filter_includes_subattributes(): + """iter_paths filters are applied to subattributes too.""" + paths = list( + Path[User].iter_paths(include_subattributes=True, required=[Required.true]) + ) + + for path in paths: + assert path.get_annotation(Required) == Required.true + + +def test_iter_paths_filter_no_match(): + """iter_paths with filter that matches nothing yields empty.""" + paths = list( + Path[User].iter_paths( + include_subattributes=False, + required=[Required.true], + mutability=[Mutability.immutable], + ) + ) + assert "userName" not in [str(p) for p in paths] + + +def test_iter_paths_filter_excludes_subattributes(): + """iter_paths filters exclude non-matching subattributes.""" + paths_with_filter = list( + Path[User].iter_paths( + include_subattributes=True, + required=[Required.true], + ) + ) + paths_without_filter = list(Path[User].iter_paths(include_subattributes=True)) + assert len(paths_with_filter) < len(paths_without_filter) + + for path in paths_with_filter: + assert path.get_annotation(Required) == Required.true + + +def test_iter_paths_filter_skips_non_matching_subattributes(): + """iter_paths skips subattributes that don't match the filter. + + Group.members has read_write mutability but its subattributes have + immutable (value, ref, type) or read_only (display) mutability. + When filtering by read_write, members is yielded but fewer subattributes. + """ + paths_with_filter = list( + Path[Group].iter_paths( + include_subattributes=True, + mutability=[Mutability.read_write], + ) + ) + paths_no_filter = list(Path[Group].iter_paths(include_subattributes=True)) + path_strings = [str(p) for p in paths_with_filter] + + assert "members" in path_strings + assert len(paths_with_filter) < len(paths_no_filter) diff --git a/tests/test_path_validation.py b/tests/test_path_validation.py deleted file mode 100644 index 3f209c4..0000000 --- a/tests/test_path_validation.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Tests for SCIM path validation utilities.""" - -from scim2_models.utils import _extract_field_name -from scim2_models.utils import _validate_scim_path_syntax -from scim2_models.utils import _validate_scim_urn_syntax - - -def test_validate_scim_path_syntax_valid_paths(): - """Test that valid SCIM paths are accepted.""" - valid_paths = [ - "userName", - "name.familyName", - "emails.value", - "groups.display", - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", - 'emails[type eq "work"].value', - 'groups[display eq "Admin"]', - "meta.lastModified", - ] - - for path in valid_paths: - assert _validate_scim_path_syntax(path), f"Path should be valid: {path}" - - -def test_validate_scim_path_syntax_invalid_paths(): - """Test that invalid SCIM paths are rejected.""" - invalid_paths = [ - "", # Empty string - " ", # Whitespace only - "123invalid", # Starts with digit - "invalid..path", # Double dots - "invalid@path", # Invalid character - "urn:invalid", # Invalid URN format - "urn:too:short", # URN too short - ] - - for path in invalid_paths: - assert not _validate_scim_path_syntax(path), f"Path should be invalid: {path}" - - -def test_validate_scim_urn_syntax_valid_urns(): - """Test that valid SCIM URN paths are accepted.""" - valid_urns = [ - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", - "urn:custom:namespace:schema:1.0:Resource:attribute", - "urn:example:extension:v2:MyResource:customField", - ] - - for urn in valid_urns: - assert _validate_scim_urn_syntax(urn), f"URN should be valid: {urn}" - - -def test_validate_scim_urn_syntax_invalid_urns(): - """Test that invalid SCIM URN paths are rejected.""" - invalid_urns = [ - "not_an_urn", # Doesn't start with urn: - "urn:too:short", # Not enough segments - "urn:ietf:params:scim:schemas:core:2.0:User:", # Empty attribute - "urn:ietf:params:scim:schemas:core:2.0:User:123invalid", # Attribute starts with digit - "urn:invalid", # Too short - "urn:only:two:attribute", # URN part too short - ] - - for urn in invalid_urns: - assert not _validate_scim_urn_syntax(urn), f"URN should be invalid: {urn}" - - -def test_validate_scim_path_syntax_edge_cases(): - """Test edge cases for path validation.""" - # Test None handling (shouldn't happen in practice but defensive) - assert not _validate_scim_path_syntax("") - - # Test borderline valid cases - assert _validate_scim_path_syntax("a") # Single character - assert _validate_scim_path_syntax("a.b") # Simple dotted - assert _validate_scim_path_syntax("a_b") # Underscore - assert _validate_scim_path_syntax("a-b") # Hyphen - - # Test borderline invalid cases - assert not _validate_scim_path_syntax("9invalid") # Starts with digit - assert not _validate_scim_path_syntax("a..b") # Double dots - - -def test_validate_scim_urn_syntax_edge_cases(): - """Test edge cases for URN validation.""" - # Test minimal valid URN - assert _validate_scim_urn_syntax("urn:a:b:c:d") - - # Test boundary cases - assert not _validate_scim_urn_syntax("urn:a:b:c:") # Empty attribute - assert not _validate_scim_urn_syntax("urn:a:b:") # Missing resource - assert not _validate_scim_urn_syntax("urn:") # Just urn: - - -def test_path_extraction(): - """Test path extraction logic.""" - # Test simple path - assert _extract_field_name("simple_field") == "simple_field" - - # Test dotted path (should return first part) - assert _extract_field_name("name.familyName") == "name" - - # Test URN path - assert ( - _extract_field_name("urn:ietf:params:scim:schemas:core:2.0:User:userName") - == "userName" - ) - - # Test invalid URN path - assert _extract_field_name("urn:invalid") is None diff --git a/tests/test_resource_extension.py b/tests/test_resource_extension.py index ec948db..2c25df2 100644 --- a/tests/test_resource_extension.py +++ b/tests/test_resource_extension.py @@ -181,27 +181,80 @@ def test_extension_validate_with_context(): def test_invalid_getitem(): - """Test that an non Resource subclass __getitem__ attribute raise a KeyError.""" + """Test that invalid paths raise KeyError.""" user = User[EnterpriseUser](user_name="foobar") with pytest.raises(KeyError): user["invalid"] - with pytest.raises(KeyError): + with pytest.raises(ValueError): user[object] def test_invalid_setitem(): - """Test that an non Resource subclass __getitem__ attribute raise a KeyError.""" + """Test that invalid paths raise KeyError.""" user = User[EnterpriseUser](user_name="foobar") with pytest.raises(KeyError): user["invalid"] = "foobar" - with pytest.raises(KeyError): + with pytest.raises(ValueError): user[object] = "foobar" +def test_getitem_by_path(): + """Access attributes using path strings.""" + user = User[EnterpriseUser](user_name="bjensen", display_name="Barbara Jensen") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + + assert user["userName"] == "bjensen" + assert user["displayName"] == "Barbara Jensen" + assert ( + user[ + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ] + == "12345" + ) + + +def test_setitem_by_path(): + """Set attributes using path strings.""" + user = User[EnterpriseUser](user_name="bjensen") + + user["displayName"] = "Barbara Jensen" + assert user.display_name == "Barbara Jensen" + + user["name.familyName"] = "Jensen" + assert user.name.family_name == "Jensen" + + +def test_delitem_by_path(): + """Delete attributes using path strings.""" + user = User(user_name="bjensen", display_name="Barbara Jensen") + + del user["displayName"] + assert user.display_name is None + + +def test_delitem_extension(): + """Delete extension using type.""" + user = User[EnterpriseUser](user_name="bjensen") + user[EnterpriseUser] = EnterpriseUser(employee_number="12345") + assert user[EnterpriseUser] is not None + + del user[EnterpriseUser] + assert user[EnterpriseUser] is None + + +def test_invalid_delitem(): + """Test that invalid paths raise KeyError on delete.""" + user = User(user_name="bjensen") + with pytest.raises(KeyError): + del user["invalid"] + + class SuperHero(Extension): - schemas: Annotated[list[str], Required.true] = ["example:extensions:SuperHero"] + schemas: Annotated[list[str], Required.true] = [ + "urn:example:extensions:2.0:SuperHero" + ] superpower: str | None = None """The superhero superpower.""" @@ -217,9 +270,9 @@ def test_multiple_extensions_union(): "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", - "example:extensions:SuperHero", + "urn:example:extensions:2.0:SuperHero", ], - "example:extensions:SuperHero": { + "urn:example:extensions:2.0:SuperHero": { "superpower": "flight", }, } diff --git a/tests/test_search_request.py b/tests/test_search_request.py index d898deb..af3b717 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -152,7 +152,7 @@ def test_search_request_invalid_excluded_attributes(): "excluded_attributes": ["valid", "123invalid"], # Second one starts with digit } - with pytest.raises(ValidationError, match="path.*invalid"): + with pytest.raises(ValidationError, match="Paths cannot start with a digit"): SearchRequest.model_validate(invalid_data) @@ -166,7 +166,7 @@ def test_search_request_invalid_sort_by(): ] for case in invalid_cases: - with pytest.raises(ValidationError, match="path.*invalid"): + with pytest.raises(ValidationError, match="path|Path"): SearchRequest.model_validate(case) @@ -239,5 +239,5 @@ def test_search_request_integration_with_existing_validation(): "excluded_attributes": ["password"], } - with pytest.raises(ValidationError, match="path.*invalid"): + with pytest.raises(ValidationError, match="path|Path"): SearchRequest.model_validate(invalid_data) diff --git a/tests/test_urn.py b/tests/test_urn.py new file mode 100644 index 0000000..33867a5 --- /dev/null +++ b/tests/test_urn.py @@ -0,0 +1,36 @@ +import pytest + +from scim2_models.base import BaseModel +from scim2_models.path import URN + + +def test_urn_syntax_valid_urns(): + """Test that valid SCIM URN paths are accepted.""" + valid_urns = [ + "urn:ietf:params:scim:schemas:core:2.0:User:userName", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + "urn:custom:namespace:schema:1.0:Resource:attribute", + "urn:example:extension:v2:MyResource:customField", + ] + + for urn in valid_urns: + URN(urn) + + +def test_urn_syntax_invalid_urns(): + """Test that invalid SCIM URN paths are rejected.""" + invalid_urns = [ + "not_an_urn", # Doesn't start with urn: + "urn:invalid", # Too short + ] + + for urn in invalid_urns: + with pytest.raises(ValueError): + URN(urn) + + +def test_urn_as_a_type(): + class Foo(BaseModel): + urn_schema: URN + + Foo.model_validate({"urn_schema": "urn:valid:schema"}) diff --git a/uv.lock b/uv.lock index c3422ae..8b916c4 100644 --- a/uv.lock +++ b/uv.lock @@ -339,7 +339,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1107,7 +1107,7 @@ doc = [ ] [package.metadata] -requires-dist = [{ name = "pydantic", extras = ["email"], specifier = ">=2.7.0" }] +requires-dist = [{ name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }] [package.metadata.requires-dev] dev = [