From ca4199377c8183a16cdfc1aa18714f3c47b040de Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 19:38:03 +0300 Subject: [PATCH 01/13] perf: Cache common values for BaseModel Updated attribute urns get/set --- scim2_models/attributes.py | 3 + scim2_models/base.py | 139 ++++++++++++++++++++++++++----------- 2 files changed, 103 insertions(+), 39 deletions(-) diff --git a/scim2_models/attributes.py b/scim2_models/attributes.py index 3764d12..ea59321 100644 --- a/scim2_models/attributes.py +++ b/scim2_models/attributes.py @@ -1,6 +1,7 @@ from inspect import isclass from typing import Annotated from typing import Any +from typing import ClassVar from typing import get_origin from pydantic import Field @@ -15,6 +16,8 @@ class ComplexAttribute(BaseModel): """A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`.""" + __is_complex_attribute__: ClassVar[bool] = True + _attribute_urn: str | None = None def get_attribute_urn(self, field_name: str) -> str: diff --git a/scim2_models/base.py b/scim2_models/base.py index d427673..e0967ae 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -2,6 +2,8 @@ from inspect import isclass from typing import Any from typing import Optional +from typing import ClassVar +from typing import NamedTuple from typing import get_args from typing import get_origin @@ -98,6 +100,22 @@ def _is_attribute_requested(requested_attrs: list[str], current_urn: str) -> boo return any(_attr_matches(req, current_urn) for req in requested_attrs) +class _SCIMClassInfo(NamedTuple): + """SCIM metadata for BaseModel.""" + + alias_to_field: dict[str, str] = {} + """Alias -> Python field name. + + Holds both validation and serialization aliases. + """ + + attribute_urns: dict[str, str] = {} + """Python field name -> fully resolved SCIM attribute URN.""" + + complex_fields: frozenset[str] = frozenset() + """Field names whose root type is a ``ComplexAttribute`` subclass.""" + + class BaseModel(PydanticBaseModel): """Base Model for everything.""" @@ -112,6 +130,9 @@ class BaseModel(PydanticBaseModel): extra="forbid", ) + __scim_info__: ClassVar[_SCIMClassInfo] = _SCIMClassInfo() + """Cached model metadata""" + @classmethod def get_field_annotation(cls, field_name: str, annotation_type: type) -> Any: """Return the annotation of type 'annotation_type' of the field 'field_name'. @@ -226,6 +247,57 @@ def get_field_multiplicity(cls, attribute_name: str) -> bool: origin = get_origin(attribute_type) return isinstance(origin, type) and issubclass(origin, list) + @classmethod + def __pydantic_on_complete__(cls) -> None: + """Build the per-class SCIM metadata table on ``cls.__scim_info__``. + + Fires after pydantic resolves field types (re-fires after ``model_rebuild``). Idempotent. + """ + if not cls.model_fields: + return + + alias_to_field: dict[str, str] = {} + attribute_urns: dict[str, str] = {} + complex_fields: set[str] = set() + + main_schema = getattr(cls, "__schema__", None) + extension_cls: type | None = None + if main_schema is not None: + from scim2_models.resources.resource import Extension + + extension_cls = Extension + + for field_name, field in cls.model_fields.items(): + # Alias -> field name mapping + serialization_alias = field.serialization_alias or field_name + alias_to_field[serialization_alias] = field_name + if isinstance(field.validation_alias, str): + alias_to_field[field.validation_alias] = field_name + + root_type = cls.get_field_root_type(field_name) + + # Is complex field + if root_type is not None and getattr( + root_type, "__is_complex_attribute__", False + ): + complex_fields.add(field_name) + + # Attribute URNs + if main_schema is not None and not ( + extension_cls is not None + and isclass(root_type) + and issubclass(root_type, extension_cls) + ): + attribute_urns[field_name] = f"{main_schema}:{serialization_alias}" + else: + attribute_urns[field_name] = serialization_alias + + cls.__scim_info__ = _SCIMClassInfo( + alias_to_field=alias_to_field, + attribute_urns=attribute_urns, + complex_fields=frozenset(complex_fields), + ) + @field_validator("*") @classmethod def check_request_attributes_mutability( @@ -504,39 +576,38 @@ def _apply_replace_constraints(self, original: Self) -> None: replacement_sub._apply_replace_constraints(original_sub) def _set_complex_attribute_urns(self) -> None: - """Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute. + """Mark each ``ComplexAttribute`` child with its ``_attribute_urn``. - '_attribute_urn' will later be used by 'get_attribute_urn'. + ``_attribute_urn`` is later read by :meth:`get_attribute_urn`. """ - from .attributes import ComplexAttribute - from .attributes import is_complex_attribute - - if isinstance(self, ComplexAttribute): - main_schema = self._attribute_urn - separator = "." - else: - main_schema = getattr(self.__class__, "__schema__", None) - if main_schema is None: - return - separator = ":" - - for field_name in self.__class__.model_fields: - attr_type = self.get_field_root_type(field_name) - if not attr_type or not is_complex_attribute(attr_type): + cls = self.__class__ + info = cls.__scim_info__ + complex_fields = info.complex_fields + if not complex_fields: + return + + is_complex = getattr(cls, "__is_complex_attribute__", False) + if is_complex and self._attribute_urn is None: + return + if not is_complex and not info.attribute_urns: + return + + for field_name in complex_fields: + attr_value = getattr(self, field_name) + if not attr_value: continue - alias = ( - self.__class__.model_fields[field_name].serialization_alias - or field_name - ) - schema = f"{main_schema}{separator}{alias}" + if is_complex: + alias = cls.model_fields[field_name].serialization_alias or field_name + schema = f"{self._attribute_urn}.{alias}" + else: + schema = info.attribute_urns[field_name] - if attr_value := getattr(self, field_name): - if isinstance(attr_value, list): - for item in attr_value: - item._attribute_urn = schema - else: - attr_value._attribute_urn = schema + if isinstance(attr_value, list): + for item in attr_value: + item._attribute_urn = schema + else: + attr_value._attribute_urn = schema @field_serializer("*", mode="wrap") def scim_serializer( @@ -659,14 +730,4 @@ def get_attribute_urn(self, field_name: str) -> str: See :rfc:`RFC7644 §3.10 <7644#section-3.10>`. """ - from scim2_models.resources.resource import Extension - - main_schema = getattr(self.__class__, "__schema__", None) - field = self.__class__.model_fields[field_name] - alias = field.serialization_alias or field_name - field_type = self.get_field_root_type(field_name) - if isclass(field_type) and issubclass(field_type, Extension): - return alias - if main_schema is None: - return alias - return f"{main_schema}:{alias}" + return self.__scim_info__.attribute_urns[field_name] From fc1c21fc9466d8cddf7385f699f6b18f68a1f830 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 19:46:53 +0300 Subject: [PATCH 02/13] perf: Simplify `normalize_attribute_names` Cache normalized names with lru_cache --- scim2_models/base.py | 45 +++---------------------------------------- scim2_models/utils.py | 2 ++ 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index e0967ae..ac9d34c 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -350,48 +350,9 @@ def normalize_attribute_names( names should be case-insensitive. Any attribute name is transformed in lowercase so any case is handled the same way. """ - - def normalize_dict_keys( - input_dict: dict[str, Any], model_class: type["BaseModel"] - ) -> dict[str, Any]: - """Normalize dictionary keys, preserving case for Any fields.""" - result = {} - - for key, val in input_dict.items(): - field_name = _find_field_name(model_class, key) - field_type = ( - model_class.get_field_root_type(field_name) if field_name else None - ) - - # Don't normalize keys for attributes typed with Any - # This way, agnostic dicts such as PatchOp.operations.value - # are preserved - if field_name and field_type == Any: - result[key] = normalize_value(val) - else: - result[_normalize_attribute_name(key)] = normalize_value( - val, field_type - ) - - return result - - def normalize_value( - val: Any, model_class: type["BaseModel"] | None = None - ) -> Any: - """Normalize input value based on model class.""" - if not isinstance(val, dict): - return val - - # If no model_class, preserve original keys - if not model_class: - return {k: normalize_value(v) for k, v in val.items()} - - return normalize_dict_keys(val, model_class) - - normalized_value = normalize_value(value, cls) - obj = handler(normalized_value) - assert isinstance(obj, cls) - return obj + if isinstance(value, dict): + value = {_normalize_attribute_name(k): v for k, v in value.items()} + return handler(value) @model_validator(mode="wrap") @classmethod diff --git a/scim2_models/utils.py b/scim2_models/utils.py index 4bddfba..0ab6163 100644 --- a/scim2_models/utils.py +++ b/scim2_models/utils.py @@ -1,6 +1,7 @@ import re from typing import TYPE_CHECKING from typing import Union +from functools import lru_cache from pydantic.alias_generators import to_snake @@ -36,6 +37,7 @@ def _to_camel(string: str) -> str: return camel +@lru_cache(maxsize=256) def _normalize_attribute_name(attribute_name: str) -> str: """Remove all non-alphabetical characters and lowerise a string. From 1dd169ece65d7c10274d6af54106f5ab0a66a6f1 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 20:05:00 +0300 Subject: [PATCH 03/13] perf: Collapse all context validators to one --- scim2_models/base.py | 291 +++++++++++++++++++------------------------ 1 file changed, 130 insertions(+), 161 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index ac9d34c..17e8ef1 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -298,47 +298,6 @@ def __pydantic_on_complete__(cls) -> None: complex_fields=frozenset(complex_fields), ) - @field_validator("*") - @classmethod - def check_request_attributes_mutability( - cls, value: Any, info: ValidationInfo - ) -> Any: - """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`.""" - if ( - not info.context - or not info.field_name - or not info.context.get("scim") - or not Context.is_request(info.context["scim"]) - ): - return value - - context = info.context.get("scim") - mutability = cls.get_field_annotation(info.field_name, Mutability) - exc = PydanticCustomError( - "mutability_error", - "Field '{field_name}' has mutability '{field_mutability}' but this in not valid in {context} context", - { - "field_name": info.field_name, - "field_mutability": mutability, - "context": context.name.lower().replace("_", " "), - }, - ) - - if ( - context in (Context.RESOURCE_QUERY_REQUEST, Context.SEARCH_REQUEST) - and mutability == Mutability.write_only - ): - raise exc - - if ( - context - in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST) - and mutability == Mutability.read_only - ): - return None - - return value - @model_validator(mode="wrap") @classmethod def normalize_attribute_names( @@ -354,145 +313,155 @@ def normalize_attribute_names( value = {_normalize_attribute_name(k): v for k, v in value.items()} return handler(value) - @model_validator(mode="wrap") - @classmethod - def check_response_attributes_returnability( - cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo - ) -> Self: - """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`.""" - obj = handler(value) - assert isinstance(obj, cls) - - if ( - not info.context - or not info.context.get("scim") - or not Context.is_response(info.context["scim"]) - ): - return obj - - for field_name in cls.model_fields: - returnability = cls.get_field_annotation(field_name, Returned) - - if returnability == Returned.always and getattr(obj, field_name) is None: - raise PydanticCustomError( - "returned_error", - "Field '{field_name}' has returnability 'always' but value is missing or null", - { - "field_name": field_name, - }, - ) - - if returnability == Returned.never and getattr(obj, field_name) is not None: - raise PydanticCustomError( - "returned_error", - "Field '{field_name}' has returnability 'never' but value is set", - { - "field_name": field_name, - }, - ) - - return obj - - @model_validator(mode="wrap") - @classmethod - def check_response_attributes_necessity( - cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo - ) -> Self: - """Check that the required attributes are present in creations and replacement requests.""" - obj = handler(value) - assert isinstance(obj, cls) - - if ( - not info.context - or not info.context.get("scim") - or info.context["scim"] - not in ( - Context.RESOURCE_CREATION_REQUEST, - Context.RESOURCE_REPLACEMENT_REQUEST, - ) - ): - return obj + @model_validator(mode="after") + def enforce_scim_context(self, info: ValidationInfo) -> Self: + scim_context = info.context.get("scim") if info.context else None + if not scim_context or scim_context == Context.DEFAULT: + return self - for field_name in cls.model_fields: - necessity = cls.get_field_annotation(field_name, Required) + from scim2_models.resources.resource import Resource - if necessity == Required.true and getattr(obj, field_name) is None: - raise PydanticCustomError( - "required_error", - "Field '{field_name}' is required but value is missing or null", - { - "field_name": field_name, - }, - ) + is_request = Context.is_request(scim_context) + is_response = Context.is_response(scim_context) + is_create_or_replace = scim_context in ( + Context.RESOURCE_CREATION_REQUEST, + Context.RESOURCE_REPLACEMENT_REQUEST, + ) + original = info.context.get("original") if info.context else None + fields_set = self.model_fields_set - return obj + for field_name in self.__class__.model_fields: + value = getattr(self, field_name) - @model_validator(mode="wrap") - @classmethod - def check_replacement_request_mutability( - cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo - ) -> Self: - """Check if 'immutable' attributes have been mutated in replacement requests.""" - from scim2_models.resources.resource import Resource + if is_request: + if field_name in fields_set: + self._check_mutability(field_name, scim_context) + if is_create_or_replace: + self._check_necessity(field_name, value) + elif is_response: + self._check_returnability(field_name, value) - obj = handler(value) - assert isinstance(obj, cls) + if ( + self.get_field_multiplicity(field_name) + and value is not None + ): + self._check_primary_uniqueness(field_name, value) - context = info.context.get("scim") if info.context else None - original = info.context.get("original") if info.context else None if ( - context == Context.RESOURCE_REPLACEMENT_REQUEST - and issubclass(cls, Resource) + scim_context == Context.RESOURCE_REPLACEMENT_REQUEST and original is not None + and issubclass(type(self), Resource) ): - try: - obj._apply_replace_constraints(original) - except MutabilityException as exc: - raise exc.as_pydantic_error() from exc - return obj + # TODO: We loop all the fields a second time + # Could replace with field specific mutability check + self._check_replacement_mutability(original) - @model_validator(mode="after") - def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self: - """Validate that only one attribute can be marked as primary in multi-valued lists. + return self - Per RFC 7643 Section 2.4: The primary attribute value 'true' MUST appear no more than once. + def _check_mutability(self, field_name: str, scim_context: Context) -> None: + """Check and fix that the field mutability is expected according to the + requests validation context, as defined in + :rfc:`RFC7643 §7 <7643#section-7>`. """ - scim_context = info.context.get("scim") if info.context else None - if not scim_context or scim_context == Context.DEFAULT: - return self + mutability = self.__class__.get_field_annotation(field_name, Mutability) - for field_name in self.__class__.model_fields: - if not self.get_field_multiplicity(field_name): - continue + if ( + scim_context in (Context.RESOURCE_QUERY_REQUEST, Context.SEARCH_REQUEST) + and mutability == Mutability.write_only + ): + raise PydanticCustomError( + "mutability_error", + "Field '{field_name}' has mutability '{field_mutability}' but this in not valid in {context} context", + { + "field_name": field_name, + "field_mutability": mutability, + "context": scim_context.name.lower().replace("_", " "), + }, + ) - field_value = getattr(self, field_name) - if field_value is None: - continue + elif ( + scim_context + in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST) + and mutability == Mutability.read_only + ): + # Avoid re-triggering this validation by using __dict__ + self.__dict__[field_name] = None - element_type = self.get_field_root_type(field_name) - if ( - element_type is None - or not isclass(element_type) - or not issubclass(element_type, PydanticBaseModel) - or "primary" not in element_type.model_fields - ): - continue + def _check_necessity(self, field_name: str, value: Any) -> None: + """Check that the required attributes are present in creations and + replacement requests. + """ + necessity = self.__class__.get_field_annotation(field_name, Required) + + if necessity == Required.true and value is None: + raise PydanticCustomError( + "required_error", + "Field '{field_name}' is required but value is missing or null", + { + "field_name": field_name, + }, + ) + + def _check_returnability(self, field_name: str, value: Any) -> None: + """Check that the fields returnability is expected according to the + responses validation context, as defined in + :rfc:`RFC7643 §7 <7643#section-7>`. + """ + returnability = self.__class__.get_field_annotation(field_name, Returned) + + if returnability == Returned.always and value is None: + raise PydanticCustomError( + "returned_error", + "Field '{field_name}' has returnability 'always' but value is missing or null", + { + "field_name": field_name, + }, + ) - primary_count = sum( - 1 for item in field_value if getattr(item, "primary", None) is True + elif returnability == Returned.never and value is not None: + raise PydanticCustomError( + "returned_error", + "Field '{field_name}' has returnability 'never' but value is set", + { + "field_name": field_name, + }, ) - if primary_count > 1: - raise PydanticCustomError( - "primary_uniqueness_error", - "Field '{field_name}' has {count} items marked as primary, but only one is allowed per RFC 7643", - { - "field_name": field_name, - "count": primary_count, - }, - ) + def _check_replacement_mutability(self, original: "BaseModel") -> None: + """Check if 'immutable' attributes have been mutated in replacement + requests. + """ + try: + self._apply_replace_constraints(original) + except MutabilityException as exc: + raise exc.as_pydantic_error() from exc + + def _check_primary_uniqueness(self, field_name: str, value: Any) -> None: + """Validate that only one attribute can be marked as primary in + multi-valued lists, per :rfc:`RFC7643 §2.4 <7643#section-2.4>`. + """ + element_type = self.get_field_root_type(field_name) + if ( + element_type is None + or not isclass(element_type) + or not issubclass(element_type, PydanticBaseModel) + or "primary" not in element_type.model_fields + ): + return + + primary_count = sum( + 1 for item in value if getattr(item, "primary", None) is True + ) - return self + if primary_count > 1: + raise PydanticCustomError( + "primary_uniqueness_error", + "Field '{field_name}' has {count} items marked as primary, but only one is allowed per RFC 7643", + { + "field_name": field_name, + "count": primary_count, + }, + ) def _apply_replace_constraints(self, original: Self) -> None: """Enforce RFC 7644 §3.5.1 replace (PUT) semantics. From 13fae660ef462259772598da0e73cbbd203c8bcf Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 20:18:43 +0300 Subject: [PATCH 04/13] perf: Collapse serialization to single one --- scim2_models/base.py | 131 +++++++++++++++---------------- scim2_models/messages/message.py | 9 +-- scim2_models/scim_object.py | 3 +- 3 files changed, 69 insertions(+), 74 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 17e8ef1..2f082b1 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -539,87 +539,86 @@ def _set_complex_attribute_urns(self) -> None: else: attr_value._attribute_urn = schema - @field_serializer("*", mode="wrap") + @model_serializer(mode="wrap") def scim_serializer( - self, - value: Any, - handler: SerializerFunctionWrapHandler, - info: FieldSerializationInfo, - ) -> Any: + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict[str, Any]: """Serialize the fields according to mutability indications passed in the serialization context.""" - value = handler(value) scim_ctx = info.context.get("scim") if info.context else None + is_request = Context.is_request(scim_ctx) + is_response = Context.is_response(scim_ctx) - if scim_ctx and Context.is_request(scim_ctx): - value = self._scim_request_serializer(value, info) + if is_response: + # Complex attribute urns are only used in responses + self._set_complex_attribute_urns() - if scim_ctx and Context.is_response(scim_ctx): - value = self._scim_response_serializer(value, info) + serialized = handler(self) + if not isinstance(serialized, dict): + return serialized - return value + if scim_ctx and scim_ctx != Context.DEFAULT: + if is_request: + self._scim_request_serializer(serialized, scim_ctx) + elif is_response: + included_attrs = info.context.get("scim_attributes", []) if info.context else [] + excluded_attrs = info.context.get("scim_excluded_attributes", []) if info.context else [] + self._scim_response_serializer(serialized, included_attrs, excluded_attrs) - def _scim_request_serializer(self, value: Any, info: FieldSerializationInfo) -> Any: - """Serialize the fields according to mutability indications passed in the serialization context.""" - mutability = self.get_field_annotation(info.field_name, Mutability) - scim_ctx = info.context.get("scim") if info.context else None + return {key: value for key, value in serialized.items() if value is not None} - if ( - scim_ctx - in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST) - and mutability == Mutability.read_only - ): - return None + def _scim_request_serializer(self, serialized: dict[str, Any], scim_ctx: Context) -> None: + """Serialize the fields according to mutability indications passed in the serialization context.""" + for alias in set(serialized): + field_name = self.__scim_info__.alias_to_field.get(alias) + if field_name is None: + continue - if ( - scim_ctx - in ( - Context.RESOURCE_QUERY_REQUEST, - Context.SEARCH_REQUEST, - ) - and mutability == Mutability.write_only - ): - return None + mutability = self.get_field_annotation(field_name, Mutability) - return value + if ( + scim_ctx in ( + Context.RESOURCE_CREATION_REQUEST, + Context.RESOURCE_REPLACEMENT_REQUEST + ) + and mutability == Mutability.read_only + ): + del serialized[alias] + + elif ( + scim_ctx in ( + Context.RESOURCE_QUERY_REQUEST, + Context.SEARCH_REQUEST, + ) + and mutability == Mutability.write_only + ): + del serialized[alias] def _scim_response_serializer( - self, value: Any, info: FieldSerializationInfo - ) -> Any: + self, + serialized: dict[str, Any], + included_attrs: list[str], + excluded_attrs: list[str] + ) -> None: """Serialize the fields according to returnability indications passed in the serialization context.""" - returnability = self.get_field_annotation(info.field_name, Returned) - attribute_urn = self.get_attribute_urn(info.field_name) - included_attrs = info.context.get("scim_attributes", []) if info.context else [] - excluded_attrs = ( - info.context.get("scim_excluded_attributes", []) if info.context else [] - ) - - if returnability == Returned.never: - return None - - if returnability == Returned.default and ( - ( - included_attrs - and not _is_attribute_requested(included_attrs, attribute_urn) - ) - or _exact_attr_match(excluded_attrs, attribute_urn) - ): - return None - - if returnability == Returned.request and not _exact_attr_match( - included_attrs, attribute_urn - ): - return None + for alias in set(serialized): + field_name = self.__scim_info__.alias_to_field.get(alias) + if field_name is None: + continue - return value + returnability = self.get_field_annotation(field_name, Returned) + attribute_urn = self.get_attribute_urn(field_name) - @model_serializer(mode="wrap") - def model_serializer_exclude_none( - self, handler: SerializerFunctionWrapHandler, info: SerializationInfo - ) -> dict[str, Any]: - """Remove `None` values inserted by the :meth:`~scim2_models.base.BaseModel.scim_serializer`.""" - self._set_complex_attribute_urns() - result = handler(self) - return {key: value for key, value in result.items() if value is not None} + if returnability == Returned.never: + del serialized[alias] + elif returnability == Returned.default and ( + (included_attrs and not _is_attribute_requested(included_attrs, attribute_urn)) + or _exact_attr_match(excluded_attrs, attribute_urn) + ): + del serialized[alias] + elif returnability == Returned.request and not _exact_attr_match( + included_attrs, attribute_urn + ): + del serialized[alias] @classmethod def model_validate( diff --git a/scim2_models/messages/message.py b/scim2_models/messages/message.py index 5b005d4..7693927 100644 --- a/scim2_models/messages/message.py +++ b/scim2_models/messages/message.py @@ -1,5 +1,4 @@ from collections.abc import Callable -from typing import TYPE_CHECKING from typing import Annotated from typing import Any from typing import Union @@ -16,18 +15,14 @@ from ..scim_object import ScimObject from ..utils import UNION_TYPES -if TYPE_CHECKING: - from pydantic import FieldSerializationInfo - class Message(ScimObject): """SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`.""" def _scim_response_serializer( - self, value: Any, info: "FieldSerializationInfo" - ) -> Any: + self, *args: Any, **kwargs: Any + ) -> None: """Message fields are not subject to attribute filtering.""" - return value def _create_schema_discriminator( diff --git a/scim2_models/scim_object.py b/scim2_models/scim_object.py index b48121e..dbd3b20 100644 --- a/scim2_models/scim_object.py +++ b/scim2_models/scim_object.py @@ -14,6 +14,7 @@ from typing_extensions import Self from .annotations import Required +from .annotations import Returned from .base import BaseModel from .context import Context from .path import URN @@ -65,7 +66,7 @@ def __new__( class ScimObject(BaseModel, metaclass=ScimMetaclass): __schema__: ClassVar[URN | None] = None - schemas: Annotated[list[str], Required.true] + schemas: Annotated[list[str], Required.true, Returned.always] """The "schemas" attribute is a REQUIRED attribute and is an array of Strings containing URIs that are used to indicate the namespaces of the SCIM schemas that define the attributes present in the current JSON From 184959856cf9200dc389e157af130328da40a6a5 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 20:37:33 +0300 Subject: [PATCH 05/13] perf: Move `model_dump` to `BaseModel` This allows us to delete the dict comprehension from `scim_serializer` and pydantic's none exclusion preserved --- scim2_models/base.py | 96 ++++++++++++++++++++++++++++--- scim2_models/scim_object.py | 83 -------------------------- tests/test_model_serialization.py | 11 ++++ tests/test_reference.py | 2 +- 4 files changed, 99 insertions(+), 93 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 2f082b1..159d046 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -1,5 +1,6 @@ import warnings from inspect import isclass +from typing import TYPE_CHECKING from typing import Any from typing import Optional from typing import ClassVar @@ -10,13 +11,10 @@ from pydantic import AliasGenerator from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict -from pydantic import FieldSerializationInfo from pydantic import SerializationInfo from pydantic import SerializerFunctionWrapHandler from pydantic import ValidationInfo from pydantic import ValidatorFunctionWrapHandler -from pydantic import field_serializer -from pydantic import field_validator from pydantic import model_serializer from pydantic import model_validator from pydantic_core import PydanticCustomError @@ -28,10 +26,12 @@ from scim2_models.context import Context from scim2_models.exceptions import MutabilityException from scim2_models.utils import UNION_TYPES -from scim2_models.utils import _find_field_name from scim2_models.utils import _normalize_attribute_name from scim2_models.utils import _to_camel +if TYPE_CHECKING: + from scim2_models.path import Path + def _short_attr_path(urn: str) -> str: """Extract the short attribute path from a full URN. @@ -505,6 +505,13 @@ def _apply_replace_constraints(self, original: Self) -> None: if original_sub is not None and replacement_sub is not None: replacement_sub._apply_replace_constraints(original_sub) + def get_attribute_urn(self, field_name: str) -> str: + """Build the full URN of the attribute. + + See :rfc:`RFC7644 §3.10 <7644#section-3.10>`. + """ + return self.__scim_info__.attribute_urns[field_name] + def _set_complex_attribute_urns(self) -> None: """Mark each ``ComplexAttribute`` child with its ``_attribute_urn``. @@ -564,7 +571,7 @@ def scim_serializer( excluded_attrs = info.context.get("scim_excluded_attributes", []) if info.context else [] self._scim_response_serializer(serialized, included_attrs, excluded_attrs) - return {key: value for key, value in serialized.items() if value is not None} + return serialized def _scim_request_serializer(self, serialized: dict[str, Any], scim_ctx: Context) -> None: """Serialize the fields according to mutability indications passed in the serialization context.""" @@ -654,9 +661,80 @@ def model_validate( return super().model_validate(*args, **kwargs) - def get_attribute_urn(self, field_name: str) -> str: - """Build the full URN of the attribute. + def _prepare_model_dump( + self, + scim_ctx: Context | None = Context.DEFAULT, + attributes: list["str | Path[Any]"] | None = None, + excluded_attributes: list["str | Path[Any]"] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + kwargs.setdefault("context", {}).setdefault("scim", scim_ctx) - See :rfc:`RFC7644 §3.10 <7644#section-3.10>`. + if scim_ctx: + kwargs.setdefault("exclude_none", True) + kwargs.setdefault("by_alias", True) + + if attributes: + kwargs["context"]["scim_attributes"] = [str(a) for a in attributes] + if excluded_attributes: + kwargs["context"]["scim_excluded_attributes"] = [ + str(a) for a in excluded_attributes + ] + + return kwargs + + def model_dump( + self, + *args: Any, + scim_ctx: Context | None = Context.DEFAULT, + attributes: list["str | Path[Any]"] | None = None, + excluded_attributes: list["str | Path[Any]"] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`. + + :param scim_ctx: If a SCIM context is passed, some default values of + Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM + messages. Pass :data:`None` to get the default Pydantic behavior. + :param attributes: 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. Invalid values are ignored. + :param excluded_attributes: A multi-valued list of strings indicating the names of resource + attributes to be removed from the default set of attributes to return. Invalid values are ignored. """ - return self.__scim_info__.attribute_urns[field_name] + dump_kwargs = self._prepare_model_dump( + scim_ctx, + attributes=attributes, + excluded_attributes=excluded_attributes, + **kwargs, + ) + if scim_ctx: + dump_kwargs.setdefault("mode", "json") + return super().model_dump(*args, **dump_kwargs) + + def model_dump_json( + self, + *args: Any, + scim_ctx: Context | None = Context.DEFAULT, + attributes: list["str | Path[Any]"] | None = None, + excluded_attributes: list["str | Path[Any]"] | None = None, + **kwargs: Any, + ) -> str: + """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`. + + :param scim_ctx: If a SCIM context is passed, some default values of + Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM + messages. Pass :data:`None` to get the default Pydantic behavior. + :param attributes: 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. Invalid values are ignored. + :param excluded_attributes: A multi-valued list of strings indicating the names of resource + attributes to be removed from the default set of attributes to return. Invalid values are ignored. + """ + dump_kwargs = self._prepare_model_dump( + scim_ctx, + attributes=attributes, + excluded_attributes=excluded_attributes, + **kwargs, + ) + return super().model_dump_json(*args, **dump_kwargs) diff --git a/scim2_models/scim_object.py b/scim2_models/scim_object.py index dbd3b20..434d734 100644 --- a/scim2_models/scim_object.py +++ b/scim2_models/scim_object.py @@ -1,7 +1,6 @@ """Base SCIM object classes with schema identification.""" import warnings -from typing import TYPE_CHECKING from typing import Annotated from typing import Any from typing import ClassVar @@ -18,10 +17,6 @@ from .base import BaseModel from .context import Context from .path import URN -from .path import Path - -if TYPE_CHECKING: - pass class ScimMetaclass(ModelMetaclass): @@ -103,81 +98,3 @@ def _validate_schemas_attribute( ) return obj - - def _prepare_model_dump( - self, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - kwargs.setdefault("context", {}).setdefault("scim", scim_ctx) - - if scim_ctx: - kwargs.setdefault("exclude_none", True) - kwargs.setdefault("by_alias", True) - - if attributes: - kwargs["context"]["scim_attributes"] = [str(a) for a in attributes] - if excluded_attributes: - kwargs["context"]["scim_excluded_attributes"] = [ - str(a) for a in excluded_attributes - ] - - return kwargs - - def model_dump( - self, - *args: Any, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`. - - :param scim_ctx: If a SCIM context is passed, some default values of - Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM - messages. Pass :data:`None` to get the default Pydantic behavior. - :param attributes: 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. Invalid values are ignored. - :param excluded_attributes: A multi-valued list of strings indicating the names of resource - attributes to be removed from the default set of attributes to return. Invalid values are ignored. - """ - dump_kwargs = self._prepare_model_dump( - scim_ctx, - attributes=attributes, - excluded_attributes=excluded_attributes, - **kwargs, - ) - if scim_ctx: - dump_kwargs.setdefault("mode", "json") - return super(BaseModel, self).model_dump(*args, **dump_kwargs) - - def model_dump_json( - self, - *args: Any, - scim_ctx: Context | None = Context.DEFAULT, - attributes: list[str | Path[Any]] | None = None, - excluded_attributes: list[str | Path[Any]] | None = None, - **kwargs: Any, - ) -> str: - """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`. - - :param scim_ctx: If a SCIM context is passed, some default values of - Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM - messages. Pass :data:`None` to get the default Pydantic behavior. - :param attributes: 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. Invalid values are ignored. - :param excluded_attributes: A multi-valued list of strings indicating the names of resource - attributes to be removed from the default set of attributes to return. Invalid values are ignored. - """ - dump_kwargs = self._prepare_model_dump( - scim_ctx, - attributes=attributes, - excluded_attributes=excluded_attributes, - **kwargs, - ) - return super(BaseModel, self).model_dump_json(*args, **dump_kwargs) diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index 5f41aa1..184ebde 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -94,6 +94,17 @@ def test_dump_default(mut_resource): } assert mut_resource.model_dump(scim_ctx=None) == { + "schemas": ["org:example:MutResource"], + "id": "id", + "external_id": None, + "meta": None, + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + } + + assert mut_resource.model_dump(scim_ctx=None, exclude_none=True) == { "schemas": ["org:example:MutResource"], "id": "id", "read_only": "x", diff --git a/tests/test_reference.py b/tests/test_reference.py index a637bc6..ae07cd3 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -75,7 +75,7 @@ def test_reference_serialization(): model = ReferenceTestModel(uri_ref=ref) dumped = model.model_dump() - assert dumped["uri_ref"] == "https://example.com" + assert dumped["uriRef"] == "https://example.com" def test_reference_validation_error(): From f6a2451d06d24e9d6bfd88bf8b1037d0001b3883 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 20:59:28 +0300 Subject: [PATCH 06/13] fix: Full test coverage Mainly removed unused checks --- scim2_models/base.py | 36 +++++++++++------------------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 159d046..079ca01 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -271,8 +271,7 @@ def __pydantic_on_complete__(cls) -> None: # Alias -> field name mapping serialization_alias = field.serialization_alias or field_name alias_to_field[serialization_alias] = field_name - if isinstance(field.validation_alias, str): - alias_to_field[field.validation_alias] = field_name + alias_to_field[field.validation_alias] = field_name root_type = cls.get_field_root_type(field_name) @@ -321,8 +320,6 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: from scim2_models.resources.resource import Resource - is_request = Context.is_request(scim_context) - is_response = Context.is_response(scim_context) is_create_or_replace = scim_context in ( Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST, @@ -333,12 +330,13 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: for field_name in self.__class__.model_fields: value = getattr(self, field_name) - if is_request: + if Context.is_request(scim_context): if field_name in fields_set: self._check_mutability(field_name, scim_context) if is_create_or_replace: self._check_necessity(field_name, value) - elif is_response: + else: + # Must be response self._check_returnability(field_name, value) if ( @@ -524,10 +522,6 @@ def _set_complex_attribute_urns(self) -> None: return is_complex = getattr(cls, "__is_complex_attribute__", False) - if is_complex and self._attribute_urn is None: - return - if not is_complex and not info.attribute_urns: - return for field_name in complex_fields: attr_value = getattr(self, field_name) @@ -552,34 +546,29 @@ def scim_serializer( ) -> dict[str, Any]: """Serialize the fields according to mutability indications passed in the serialization context.""" scim_ctx = info.context.get("scim") if info.context else None - is_request = Context.is_request(scim_ctx) is_response = Context.is_response(scim_ctx) if is_response: # Complex attribute urns are only used in responses self._set_complex_attribute_urns() - serialized = handler(self) - if not isinstance(serialized, dict): - return serialized + serialized: dict[str, Any] = handler(self) if scim_ctx and scim_ctx != Context.DEFAULT: - if is_request: - self._scim_request_serializer(serialized, scim_ctx) - elif is_response: + if is_response: included_attrs = info.context.get("scim_attributes", []) if info.context else [] excluded_attrs = info.context.get("scim_excluded_attributes", []) if info.context else [] self._scim_response_serializer(serialized, included_attrs, excluded_attrs) + else: + # Must be request + self._scim_request_serializer(serialized, scim_ctx) return serialized def _scim_request_serializer(self, serialized: dict[str, Any], scim_ctx: Context) -> None: """Serialize the fields according to mutability indications passed in the serialization context.""" for alias in set(serialized): - field_name = self.__scim_info__.alias_to_field.get(alias) - if field_name is None: - continue - + field_name = self.__scim_info__.alias_to_field[alias] mutability = self.get_field_annotation(field_name, Mutability) if ( @@ -608,10 +597,7 @@ def _scim_response_serializer( ) -> None: """Serialize the fields according to returnability indications passed in the serialization context.""" for alias in set(serialized): - field_name = self.__scim_info__.alias_to_field.get(alias) - if field_name is None: - continue - + field_name = self.__scim_info__.alias_to_field[alias] returnability = self.get_field_annotation(field_name, Returned) attribute_urn = self.get_attribute_urn(field_name) From a6488336a17c771dd47b70e4118c348f9a0697db Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Tue, 12 May 2026 22:16:01 +0300 Subject: [PATCH 07/13] fix: Pass formatting Simplified `_set_complex_attribute_urns` even more --- scim2_models/base.py | 79 +++++++++++++++----------------- scim2_models/messages/message.py | 4 +- scim2_models/utils.py | 2 +- 3 files changed, 38 insertions(+), 47 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 079ca01..a5404f2 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -2,9 +2,10 @@ from inspect import isclass from typing import TYPE_CHECKING from typing import Any -from typing import Optional from typing import ClassVar from typing import NamedTuple +from typing import Optional +from typing import cast from typing import get_args from typing import get_origin @@ -271,7 +272,7 @@ def __pydantic_on_complete__(cls) -> None: # Alias -> field name mapping serialization_alias = field.serialization_alias or field_name alias_to_field[serialization_alias] = field_name - alias_to_field[field.validation_alias] = field_name + alias_to_field[cast(str, field.validation_alias)] = field_name root_type = cls.get_field_root_type(field_name) @@ -310,7 +311,7 @@ def normalize_attribute_names( """ if isinstance(value, dict): value = {_normalize_attribute_name(k): v for k, v in value.items()} - return handler(value) + return cast(Self, handler(value)) @model_validator(mode="after") def enforce_scim_context(self, info: ValidationInfo) -> Self: @@ -339,10 +340,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: # Must be response self._check_returnability(field_name, value) - if ( - self.get_field_multiplicity(field_name) - and value is not None - ): + if self.get_field_multiplicity(field_name) and value is not None: self._check_primary_uniqueness(field_name, value) if ( @@ -357,10 +355,7 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: return self def _check_mutability(self, field_name: str, scim_context: Context) -> None: - """Check and fix that the field mutability is expected according to the - requests validation context, as defined in - :rfc:`RFC7643 §7 <7643#section-7>`. - """ + """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`.""" mutability = self.__class__.get_field_annotation(field_name, Mutability) if ( @@ -386,9 +381,7 @@ def _check_mutability(self, field_name: str, scim_context: Context) -> None: self.__dict__[field_name] = None def _check_necessity(self, field_name: str, value: Any) -> None: - """Check that the required attributes are present in creations and - replacement requests. - """ + """Check that the required attributes are present in creations and replacement requests.""" necessity = self.__class__.get_field_annotation(field_name, Required) if necessity == Required.true and value is None: @@ -401,10 +394,7 @@ def _check_necessity(self, field_name: str, value: Any) -> None: ) def _check_returnability(self, field_name: str, value: Any) -> None: - """Check that the fields returnability is expected according to the - responses validation context, as defined in - :rfc:`RFC7643 §7 <7643#section-7>`. - """ + """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`.""" returnability = self.__class__.get_field_annotation(field_name, Returned) if returnability == Returned.always and value is None: @@ -426,18 +416,14 @@ def _check_returnability(self, field_name: str, value: Any) -> None: ) def _check_replacement_mutability(self, original: "BaseModel") -> None: - """Check if 'immutable' attributes have been mutated in replacement - requests. - """ + """Check if 'immutable' attributes have been mutated in replacement requests.""" try: self._apply_replace_constraints(original) except MutabilityException as exc: raise exc.as_pydantic_error() from exc def _check_primary_uniqueness(self, field_name: str, value: Any) -> None: - """Validate that only one attribute can be marked as primary in - multi-valued lists, per :rfc:`RFC7643 §2.4 <7643#section-2.4>`. - """ + """Validate that only one attribute can be marked as primary in multi-valued lists, per :rfc:`RFC7643 §2.4 <7643#section-2.4>`.""" element_type = self.get_field_root_type(field_name) if ( element_type is None @@ -518,21 +504,13 @@ def _set_complex_attribute_urns(self) -> None: cls = self.__class__ info = cls.__scim_info__ complex_fields = info.complex_fields - if not complex_fields: - return - - is_complex = getattr(cls, "__is_complex_attribute__", False) for field_name in complex_fields: attr_value = getattr(self, field_name) if not attr_value: continue - if is_complex: - alias = cls.model_fields[field_name].serialization_alias or field_name - schema = f"{self._attribute_urn}.{alias}" - else: - schema = info.attribute_urns[field_name] + schema = info.attribute_urns[field_name] if isinstance(attr_value, list): for item in attr_value: @@ -546,7 +524,7 @@ def scim_serializer( ) -> dict[str, Any]: """Serialize the fields according to mutability indications passed in the serialization context.""" scim_ctx = info.context.get("scim") if info.context else None - is_response = Context.is_response(scim_ctx) + is_response = Context.is_response(scim_ctx) if scim_ctx else False if is_response: # Complex attribute urns are only used in responses @@ -556,32 +534,44 @@ def scim_serializer( if scim_ctx and scim_ctx != Context.DEFAULT: if is_response: - included_attrs = info.context.get("scim_attributes", []) if info.context else [] - excluded_attrs = info.context.get("scim_excluded_attributes", []) if info.context else [] - self._scim_response_serializer(serialized, included_attrs, excluded_attrs) + included_attrs = ( + info.context.get("scim_attributes", []) if info.context else [] + ) + excluded_attrs = ( + info.context.get("scim_excluded_attributes", []) + if info.context + else [] + ) + self._scim_response_serializer( + serialized, included_attrs, excluded_attrs + ) else: # Must be request self._scim_request_serializer(serialized, scim_ctx) return serialized - def _scim_request_serializer(self, serialized: dict[str, Any], scim_ctx: Context) -> None: + def _scim_request_serializer( + self, serialized: dict[str, Any], scim_ctx: Context + ) -> None: """Serialize the fields according to mutability indications passed in the serialization context.""" for alias in set(serialized): field_name = self.__scim_info__.alias_to_field[alias] mutability = self.get_field_annotation(field_name, Mutability) if ( - scim_ctx in ( + scim_ctx + in ( Context.RESOURCE_CREATION_REQUEST, - Context.RESOURCE_REPLACEMENT_REQUEST + Context.RESOURCE_REPLACEMENT_REQUEST, ) and mutability == Mutability.read_only ): del serialized[alias] elif ( - scim_ctx in ( + scim_ctx + in ( Context.RESOURCE_QUERY_REQUEST, Context.SEARCH_REQUEST, ) @@ -593,7 +583,7 @@ def _scim_response_serializer( self, serialized: dict[str, Any], included_attrs: list[str], - excluded_attrs: list[str] + excluded_attrs: list[str], ) -> None: """Serialize the fields according to returnability indications passed in the serialization context.""" for alias in set(serialized): @@ -604,7 +594,10 @@ def _scim_response_serializer( if returnability == Returned.never: del serialized[alias] elif returnability == Returned.default and ( - (included_attrs and not _is_attribute_requested(included_attrs, attribute_urn)) + ( + included_attrs + and not _is_attribute_requested(included_attrs, attribute_urn) + ) or _exact_attr_match(excluded_attrs, attribute_urn) ): del serialized[alias] diff --git a/scim2_models/messages/message.py b/scim2_models/messages/message.py index 7693927..2b75665 100644 --- a/scim2_models/messages/message.py +++ b/scim2_models/messages/message.py @@ -19,9 +19,7 @@ class Message(ScimObject): """SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`.""" - def _scim_response_serializer( - self, *args: Any, **kwargs: Any - ) -> None: + def _scim_response_serializer(self, *args: Any, **kwargs: Any) -> None: """Message fields are not subject to attribute filtering.""" diff --git a/scim2_models/utils.py b/scim2_models/utils.py index 0ab6163..70b2ace 100644 --- a/scim2_models/utils.py +++ b/scim2_models/utils.py @@ -1,7 +1,7 @@ import re +from functools import lru_cache from typing import TYPE_CHECKING from typing import Union -from functools import lru_cache from pydantic.alias_generators import to_snake From 767c924fd3e1094bd224ee1e368828108bc5ceec Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Wed, 13 May 2026 19:33:57 +0300 Subject: [PATCH 08/13] tests: Serialization and validation tests Test model serialization and validation with extensions --- tests/test_model_serialization.py | 207 ++++++++++++++++++++++++++++-- tests/test_model_validation.py | 43 +++++++ 2 files changed, 239 insertions(+), 11 deletions(-) diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index 184ebde..ca07db4 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -4,11 +4,11 @@ from scim2_models import URN from scim2_models.annotations import Mutability -from scim2_models.annotations import Required from scim2_models.annotations import Returned from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context from scim2_models.resources.resource import Resource +from scim2_models.resources.resource import Extension class SubRetModel(ComplexAttribute): @@ -30,7 +30,16 @@ class SupRetResource(Resource): class MutResource(Resource): - schemas: Annotated[list[str], Required.true] = ["org:example:MutResource"] + __schema__ = URN("urn:org:example:MutResource") + + read_only: Annotated[str | None, Mutability.read_only] = None + read_write: Annotated[str | None, Mutability.read_write] = None + immutable: Annotated[str | None, Mutability.immutable] = None + write_only: Annotated[str | None, Mutability.write_only] = None + + +class MutExtension(Extension): + __schema__ = URN("urn:org:extensions:MutExtension") read_only: Annotated[str | None, Mutability.read_only] = None read_write: Annotated[str | None, Mutability.read_write] = None @@ -66,17 +75,48 @@ def mut_resource(): ) +@pytest.fixture +def mut_resource_extension(): + resource = MutResource[MutExtension]( + id="id", + read_only="x", + read_write="x", + immutable="x", + write_only="x", + ) + resource[MutExtension] = MutExtension( + read_only="y", + read_write="y", + immutable="y", + write_only="y", + ) + return resource + + +@pytest.fixture +def mut_resource_extension_empty(): + resource = MutResource[MutExtension]( + id="id", + read_only="x", + read_write="x", + immutable="x", + write_only="x", + ) + resource[MutExtension] = MutExtension() + return resource + + def test_model_dump_json(mut_resource): assert ( mut_resource.model_dump_json() - == '{"schemas":["org:example:MutResource"],"id":"id","readOnly":"x","readWrite":"x","immutable":"x","writeOnly":"x"}' + == '{"schemas":["urn:org:example:MutResource"],"id":"id","readOnly":"x","readWrite":"x","immutable":"x","writeOnly":"x"}' ) def test_dump_default(mut_resource): """By default, everything is dumped.""" assert mut_resource.model_dump() == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "readOnly": "x", "readWrite": "x", @@ -85,7 +125,7 @@ def test_dump_default(mut_resource): } assert mut_resource.model_dump(scim_ctx=Context.DEFAULT) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "readOnly": "x", "readWrite": "x", @@ -94,7 +134,7 @@ def test_dump_default(mut_resource): } assert mut_resource.model_dump(scim_ctx=None) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "external_id": None, "meta": None, @@ -105,7 +145,7 @@ def test_dump_default(mut_resource): } assert mut_resource.model_dump(scim_ctx=None, exclude_none=True) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "read_only": "x", "read_write": "x", @@ -114,6 +154,151 @@ def test_dump_default(mut_resource): } +def test_dump_extension(mut_resource_extension): + """Test dumps with extension""" + assert mut_resource_extension.model_dump() == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "readOnly": "x", + "readWrite": "x", + "immutable": "x", + "writeOnly": "x", + "urn:org:extensions:MutExtension": { + "readOnly": "y", + "readWrite": "y", + "immutable": "y", + "writeOnly": "y", + } + } + + assert mut_resource_extension.model_dump(scim_ctx=None) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "external_id": None, + "meta": None, + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + "MutExtension": { + "read_only": "y", + "read_write": "y", + "immutable": "y", + "write_only": "y", + } + } + + assert mut_resource_extension.model_dump(scim_ctx=None, exclude_none=True) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + "MutExtension": { + "read_only": "y", + "read_write": "y", + "immutable": "y", + "write_only": "y", + } + } + + assert mut_resource_extension.model_dump(by_alias=False) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + "MutExtension": { + "read_only": "y", + "read_write": "y", + "immutable": "y", + "write_only": "y", + } + } + + assert mut_resource_extension.model_dump(scim_ctx=None, by_alias=True) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "externalId": None, + "meta": None, + "readOnly": "x", + "readWrite": "x", + "immutable": "x", + "writeOnly": "x", + "urn:org:extensions:MutExtension": { + "readOnly": "y", + "readWrite": "y", + "immutable": "y", + "writeOnly": "y", + } + } + +def test_dump_empty_extension(mut_resource_extension_empty): + """Test dumps with empty extension""" + assert mut_resource_extension_empty.model_dump() == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "readOnly": "x", + "readWrite": "x", + "immutable": "x", + "writeOnly": "x", + } + + assert mut_resource_extension_empty.model_dump(scim_ctx=None) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "external_id": None, + "meta": None, + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + "MutExtension": { + "read_only": None, + "read_write": None, + "immutable": None, + "write_only": None, + } + } + + assert mut_resource_extension_empty.model_dump(scim_ctx=None, exclude_none=True) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + } + + assert mut_resource_extension_empty.model_dump(by_alias=False) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "read_only": "x", + "read_write": "x", + "immutable": "x", + "write_only": "x", + } + + assert mut_resource_extension_empty.model_dump(scim_ctx=None, by_alias=True) == { + "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], + "id": "id", + "externalId": None, + "meta": None, + "readOnly": "x", + "readWrite": "x", + "immutable": "x", + "writeOnly": "x", + "urn:org:extensions:MutExtension": { + "readOnly": None, + "readWrite": None, + "immutable": None, + "writeOnly": None, + } + } + + def test_dump_creation_request(mut_resource): """Test query building for resource creation request. @@ -124,7 +309,7 @@ def test_dump_creation_request(mut_resource): - Mutability.read_only are not dumped """ assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "readWrite": "x", "immutable": "x", "writeOnly": "x", @@ -141,7 +326,7 @@ def test_dump_query_request(mut_resource): - Mutability.read_only are dumped """ assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_REQUEST) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "readOnly": "x", "readWrite": "x", @@ -159,7 +344,7 @@ def test_dump_replacement_request(mut_resource): - Mutability.read_only are not dumped """ assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "readWrite": "x", "writeOnly": "x", "immutable": "x", @@ -176,7 +361,7 @@ def test_dump_search_request(mut_resource): - Mutability.read_only are dumped """ assert mut_resource.model_dump(scim_ctx=Context.RESOURCE_QUERY_REQUEST) == { - "schemas": ["org:example:MutResource"], + "schemas": ["urn:org:example:MutResource"], "id": "id", "readOnly": "x", "readWrite": "x", diff --git a/tests/test_model_validation.py b/tests/test_model_validation.py index 0c3d6f8..7a0350e 100644 --- a/tests/test_model_validation.py +++ b/tests/test_model_validation.py @@ -332,6 +332,49 @@ class Super(Resource): assert replacement.sub.read_write == "new" +def test_replace_detects_changed_immutable_in_extension(): + """Replace detects changes in immutable fields inside extensions.""" + from scim2_models import URN + from scim2_models import Extension + from scim2_models.exceptions import MutabilityException + + class MyExt(Extension): + __schema__ = URN("urn:example:extensions:2.0:MyExt") + immutable: Annotated[str | None, Mutability.immutable] = None + + class MyResource(Resource): + __schema__ = URN("urn:example:resources:2.0:MyResource") + + original = MyResource[MyExt]() + original[MyExt] = MyExt(immutable="x") + replacement = MyResource[MyExt]() + replacement[MyExt] = MyExt(immutable="y") + with pytest.raises(MutabilityException): + replacement.replace(original) + + +def test_replace_copies_read_only_in_extension(): + """Replace copies readOnly fields from original inside extensions.""" + from scim2_models import URN + from scim2_models import Extension + + class MyExt(Extension): + __schema__ = URN("urn:example:extensions:2.0:MyExt") + read_only: Annotated[str | None, Mutability.read_only] = None + read_write: Annotated[str | None, Mutability.read_write] = None + + class MyResource(Resource): + __schema__ = URN("urn:example:resources:2.0:MyResource") + + original = MyResource[MyExt]() + original[MyExt] = MyExt(read_only="server", read_write="old") + replacement = MyResource[MyExt]() + replacement[MyExt] = MyExt(read_only="client", read_write="new") + replacement.replace(original) + assert replacement[MyExt].read_only == "server" + assert replacement[MyExt].read_write == "new" + + def test_original_parameter_emits_deprecation_warning(): """Passing 'original' to model_validate emits a DeprecationWarning.""" original = MutResource(immutable="y") From 24ca797845c6216ad0ef4f7ca245908fe456f50b Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Wed, 13 May 2026 19:49:14 +0300 Subject: [PATCH 09/13] fix: Exclude extension if all fields are None --- scim2_models/base.py | 17 ++++++++++++++--- scim2_models/resources/resource.py | 4 ++++ tests/test_model_serialization.py | 7 +++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index a5404f2..4657e01 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -532,7 +532,18 @@ def scim_serializer( serialized: dict[str, Any] = handler(self) - if scim_ctx and scim_ctx != Context.DEFAULT: + if not scim_ctx: + return serialized + + # Delete empty extensions + if (get_extension_models := getattr(self, "get_extension_models", None)) is not None: + for ext_urn, ext_cls in get_extension_models().items(): + key = ext_urn if info.by_alias else ext_cls.__name__ + if key in serialized and serialized[key] is None: + del serialized[key] + + # Serialize according to given context + if scim_ctx != Context.DEFAULT: if is_response: included_attrs = ( info.context.get("scim_attributes", []) if info.context else [] @@ -556,7 +567,7 @@ def _scim_request_serializer( ) -> None: """Serialize the fields according to mutability indications passed in the serialization context.""" for alias in set(serialized): - field_name = self.__scim_info__.alias_to_field[alias] + field_name = self.__scim_info__.alias_to_field.get(alias, alias) mutability = self.get_field_annotation(field_name, Mutability) if ( @@ -587,7 +598,7 @@ def _scim_response_serializer( ) -> None: """Serialize the fields according to returnability indications passed in the serialization context.""" for alias in set(serialized): - field_name = self.__scim_info__.alias_to_field[alias] + field_name = self.__scim_info__.alias_to_field.get(alias, alias) returnability = self.get_field_annotation(field_name, Returned) attribute_urn = self.get_attribute_urn(field_name) diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index e0c13b7..fdcca91 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -123,6 +123,10 @@ def _extension_serializer( partial_result = handler(value) + scim_context = info.context.get("scim") if info.context else None + if not scim_context: + return partial_result + result = { attr_name: value for attr_name, value in partial_result.items() diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index ca07db4..d7d2876 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -181,6 +181,7 @@ def test_dump_extension(mut_resource_extension): "immutable": "x", "write_only": "x", "MutExtension": { + "schemas": ["urn:org:extensions:MutExtension"], "read_only": "y", "read_write": "y", "immutable": "y", @@ -196,6 +197,7 @@ def test_dump_extension(mut_resource_extension): "immutable": "x", "write_only": "x", "MutExtension": { + "schemas": ["urn:org:extensions:MutExtension"], "read_only": "y", "read_write": "y", "immutable": "y", @@ -228,6 +230,7 @@ def test_dump_extension(mut_resource_extension): "immutable": "x", "writeOnly": "x", "urn:org:extensions:MutExtension": { + "schemas": ["urn:org:extensions:MutExtension"], "readOnly": "y", "readWrite": "y", "immutable": "y", @@ -235,6 +238,7 @@ def test_dump_extension(mut_resource_extension): } } + def test_dump_empty_extension(mut_resource_extension_empty): """Test dumps with empty extension""" assert mut_resource_extension_empty.model_dump() == { @@ -256,6 +260,7 @@ def test_dump_empty_extension(mut_resource_extension_empty): "immutable": "x", "write_only": "x", "MutExtension": { + "schemas": ["urn:org:extensions:MutExtension"], "read_only": None, "read_write": None, "immutable": None, @@ -270,6 +275,7 @@ def test_dump_empty_extension(mut_resource_extension_empty): "read_write": "x", "immutable": "x", "write_only": "x", + "MutExtension": {"schemas": ["urn:org:extensions:MutExtension"]} } assert mut_resource_extension_empty.model_dump(by_alias=False) == { @@ -291,6 +297,7 @@ def test_dump_empty_extension(mut_resource_extension_empty): "immutable": "x", "writeOnly": "x", "urn:org:extensions:MutExtension": { + "schemas": ["urn:org:extensions:MutExtension"], "readOnly": None, "readWrite": None, "immutable": None, From 2fabee856ac52ad0517d036c10c8760ea47717dc Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Mon, 18 May 2026 19:57:02 +0300 Subject: [PATCH 10/13] fix: Check extension replace constraints Added `extensions` to lookup table. In `_apply_replace_constraints` we just loop through complex attributes and extensions for deep replace check --- scim2_models/base.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index 4657e01..af04fbf 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -116,6 +116,9 @@ class _SCIMClassInfo(NamedTuple): complex_fields: frozenset[str] = frozenset() """Field names whose root type is a ``ComplexAttribute`` subclass.""" + extensions: frozenset[set] = frozenset() + """Field names whose root type is a ``Extension`` subclass.""" + class BaseModel(PydanticBaseModel): """Base Model for everything.""" @@ -260,6 +263,7 @@ def __pydantic_on_complete__(cls) -> None: alias_to_field: dict[str, str] = {} attribute_urns: dict[str, str] = {} complex_fields: set[str] = set() + extensions: set[str] = set() main_schema = getattr(cls, "__schema__", None) extension_cls: type | None = None @@ -282,12 +286,16 @@ def __pydantic_on_complete__(cls) -> None: ): complex_fields.add(field_name) - # Attribute URNs - if main_schema is not None and not ( - extension_cls is not None + # Is extension + if ( + main_schema and isclass(root_type) and issubclass(root_type, extension_cls) ): + extensions.add(field_name) + + # Attribute URNs + if main_schema is not None and field_name not in extensions: attribute_urns[field_name] = f"{main_schema}:{serialization_alias}" else: attribute_urns[field_name] = serialization_alias @@ -296,6 +304,7 @@ def __pydantic_on_complete__(cls) -> None: alias_to_field=alias_to_field, attribute_urns=attribute_urns, complex_fields=frozenset(complex_fields), + extensions=frozenset(extensions), ) @model_validator(mode="wrap") @@ -343,13 +352,12 @@ def enforce_scim_context(self, info: ValidationInfo) -> Self: if self.get_field_multiplicity(field_name) and value is not None: self._check_primary_uniqueness(field_name, value) + # DEPRECATED: Remove when original is not used in validation if ( scim_context == Context.RESOURCE_REPLACEMENT_REQUEST and original is not None and issubclass(type(self), Resource) ): - # TODO: We loop all the fields a second time - # Could replace with field specific mutability check self._check_replacement_mutability(original) return self @@ -457,7 +465,6 @@ def _apply_replace_constraints(self, original: Self) -> None: Recursively applies to nested single-valued complex attributes. """ - from .attributes import is_complex_attribute for field_name in type(self).model_fields: mutability = type(self).get_field_annotation(field_name, Mutability) @@ -465,30 +472,28 @@ def _apply_replace_constraints(self, original: Self) -> None: if mutability == Mutability.read_only: # RFC 7644 §3.5.1: "readOnly" values provided SHALL be ignored. - setattr(self, field_name, original_val) + self.__dict__[field_name] = original_val elif mutability == Mutability.immutable: self_val = getattr(self, field_name) if self_val is None and original_val is not None: # RFC 7643 §7: "SHALL NOT be updated" — omitting an # immutable field is not a request to clear it. - setattr(self, field_name, original_val) + self.__dict__[field_name] = original_val elif self_val != original_val: # RFC 7644 §3.5.1: input values MUST match. raise MutabilityException( attribute=field_name, mutability="immutable" ) - attr_type = type(self).get_field_root_type(field_name) - if ( - attr_type - and is_complex_attribute(attr_type) - and not type(self).get_field_multiplicity(field_name) - ): - original_sub = getattr(original, field_name) - replacement_sub = getattr(self, field_name) + complex_and_extensions = self.__scim_info__.complex_fields.union(self.__scim_info__.extensions) + for complex_attr in complex_and_extensions: + if not type(self).get_field_multiplicity(complex_attr): + original_sub = getattr(original, complex_attr) + replacement_sub = getattr(self, complex_attr) if original_sub is not None and replacement_sub is not None: replacement_sub._apply_replace_constraints(original_sub) + def get_attribute_urn(self, field_name: str) -> str: """Build the full URN of the attribute. From cc176518a8bf5806e023ca8e236172b3c93ef534 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Mon, 18 May 2026 20:15:40 +0300 Subject: [PATCH 11/13] refactor: Use extension map in `scim_serializer` --- scim2_models/base.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index af04fbf..f326a1d 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -541,11 +541,10 @@ def scim_serializer( return serialized # Delete empty extensions - if (get_extension_models := getattr(self, "get_extension_models", None)) is not None: - for ext_urn, ext_cls in get_extension_models().items(): - key = ext_urn if info.by_alias else ext_cls.__name__ - if key in serialized and serialized[key] is None: - del serialized[key] + for extension_field in self.__scim_info__.extensions: + key = self.__scim_info__.attribute_urns[extension_field] if info.by_alias else extension_field + if key in serialized and serialized[key] is None: + del serialized[key] # Serialize according to given context if scim_ctx != Context.DEFAULT: From e934f82b0f6877697b91b0d3ea4cc3f42145fa39 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Wed, 20 May 2026 19:21:45 +0300 Subject: [PATCH 12/13] chore: Pass code style --- scim2_models/base.py | 16 ++++++++++------ scim2_models/resources/resource.py | 2 +- tests/test_model_serialization.py | 26 ++++++++++++++------------ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/scim2_models/base.py b/scim2_models/base.py index f326a1d..223ac25 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -116,7 +116,7 @@ class _SCIMClassInfo(NamedTuple): complex_fields: frozenset[str] = frozenset() """Field names whose root type is a ``ComplexAttribute`` subclass.""" - extensions: frozenset[set] = frozenset() + extensions: frozenset[str] = frozenset() """Field names whose root type is a ``Extension`` subclass.""" @@ -288,7 +288,7 @@ def __pydantic_on_complete__(cls) -> None: # Is extension if ( - main_schema + extension_cls is not None and isclass(root_type) and issubclass(root_type, extension_cls) ): @@ -465,7 +465,6 @@ def _apply_replace_constraints(self, original: Self) -> None: Recursively applies to nested single-valued complex attributes. """ - for field_name in type(self).model_fields: mutability = type(self).get_field_annotation(field_name, Mutability) original_val = getattr(original, field_name) @@ -485,7 +484,9 @@ def _apply_replace_constraints(self, original: Self) -> None: attribute=field_name, mutability="immutable" ) - complex_and_extensions = self.__scim_info__.complex_fields.union(self.__scim_info__.extensions) + complex_and_extensions = self.__scim_info__.complex_fields.union( + self.__scim_info__.extensions + ) for complex_attr in complex_and_extensions: if not type(self).get_field_multiplicity(complex_attr): original_sub = getattr(original, complex_attr) @@ -493,7 +494,6 @@ def _apply_replace_constraints(self, original: Self) -> None: if original_sub is not None and replacement_sub is not None: replacement_sub._apply_replace_constraints(original_sub) - def get_attribute_urn(self, field_name: str) -> str: """Build the full URN of the attribute. @@ -542,7 +542,11 @@ def scim_serializer( # Delete empty extensions for extension_field in self.__scim_info__.extensions: - key = self.__scim_info__.attribute_urns[extension_field] if info.by_alias else extension_field + key = ( + self.__scim_info__.attribute_urns[extension_field] + if info.by_alias + else extension_field + ) if key in serialized and serialized[key] is None: del serialized[key] diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index fdcca91..278bb86 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -112,7 +112,7 @@ def from_schema(cls, schema: "Schema") -> type["Extension"]: def _extension_serializer( value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo -) -> dict[str, Any] | None: +) -> Any: """Exclude the Resource attributes from the extension dump. For instance, attributes 'meta', 'id' or 'schemas' should not be diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index d7d2876..44edbcd 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -7,8 +7,8 @@ from scim2_models.annotations import Returned from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context -from scim2_models.resources.resource import Resource from scim2_models.resources.resource import Extension +from scim2_models.resources.resource import Resource class SubRetModel(ComplexAttribute): @@ -155,7 +155,7 @@ def test_dump_default(mut_resource): def test_dump_extension(mut_resource_extension): - """Test dumps with extension""" + """Test dumps with extension.""" assert mut_resource_extension.model_dump() == { "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], "id": "id", @@ -168,7 +168,7 @@ def test_dump_extension(mut_resource_extension): "readWrite": "y", "immutable": "y", "writeOnly": "y", - } + }, } assert mut_resource_extension.model_dump(scim_ctx=None) == { @@ -186,7 +186,7 @@ def test_dump_extension(mut_resource_extension): "read_write": "y", "immutable": "y", "write_only": "y", - } + }, } assert mut_resource_extension.model_dump(scim_ctx=None, exclude_none=True) == { @@ -202,7 +202,7 @@ def test_dump_extension(mut_resource_extension): "read_write": "y", "immutable": "y", "write_only": "y", - } + }, } assert mut_resource_extension.model_dump(by_alias=False) == { @@ -217,7 +217,7 @@ def test_dump_extension(mut_resource_extension): "read_write": "y", "immutable": "y", "write_only": "y", - } + }, } assert mut_resource_extension.model_dump(scim_ctx=None, by_alias=True) == { @@ -235,12 +235,12 @@ def test_dump_extension(mut_resource_extension): "readWrite": "y", "immutable": "y", "writeOnly": "y", - } + }, } def test_dump_empty_extension(mut_resource_extension_empty): - """Test dumps with empty extension""" + """Test dumps with empty extension.""" assert mut_resource_extension_empty.model_dump() == { "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], "id": "id", @@ -265,17 +265,19 @@ def test_dump_empty_extension(mut_resource_extension_empty): "read_write": None, "immutable": None, "write_only": None, - } + }, } - assert mut_resource_extension_empty.model_dump(scim_ctx=None, exclude_none=True) == { + assert mut_resource_extension_empty.model_dump( + scim_ctx=None, exclude_none=True + ) == { "schemas": ["urn:org:example:MutResource", "urn:org:extensions:MutExtension"], "id": "id", "read_only": "x", "read_write": "x", "immutable": "x", "write_only": "x", - "MutExtension": {"schemas": ["urn:org:extensions:MutExtension"]} + "MutExtension": {"schemas": ["urn:org:extensions:MutExtension"]}, } assert mut_resource_extension_empty.model_dump(by_alias=False) == { @@ -302,7 +304,7 @@ def test_dump_empty_extension(mut_resource_extension_empty): "readWrite": None, "immutable": None, "writeOnly": None, - } + }, } From d215968b097c255d748959166c2c70948e4b94e0 Mon Sep 17 00:00:00 2001 From: NaqGuug Date: Wed, 20 May 2026 20:06:33 +0300 Subject: [PATCH 13/13] docs: Updated changelog --- doc/changelog.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index 4ba907a..645686f 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,23 @@ Changelog ========= +[Unreleased] +------------ + +Performance +^^^^^^^^^^^ +- Cached commonly used metadata of fields to :attr:`~scim2_models.BaseModel.__scim_info__`. +- Collapsed all scim context validators in :class:`~scim2_models.BaseModel` to one model validator. +- Collapsed serialization to one model serializer in :class:`~scim2_models.BaseModel`. +- Moved ``model_dump`` and ``model_dump_json`` to :class:`~scim2_models.BaseModel`. +- Cached ``_normalize_attribute_name``. +- Simplified ``normalize_attribute_names`` + +Fixed +^^^^^ +- Check recursively extensions' replace constraints. +- The result of ``model_dump`` does not differ from native pydantic's dump if ``scim_ctx`` is set to ``None``. + [0.6.12] - 2026-04-13 ---------------------