diff --git a/doc/changelog.rst b/doc/changelog.rst index 4ba907ad..9e6dbb2b 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,16 @@ Changelog ========= +[0.6.13] - 2026-07-27 +--------------------- + +Fixed +^^^^^ +- ``reference`` and ``binary`` attributes are case-exact, unless a schema explicitly states otherwise. :rfc:`7643` §2.3.6 and §2.3.7, `erratum 6001 `_ +- :class:`~scim2_models.ResourceType` ``endpoint`` is case-exact. :rfc:`7643` `erratum 8475 `_ +- :class:`~scim2_models.GroupMember` and :class:`~scim2_models.GroupMembership` ``value`` are case-exact, as they hold resource ``id`` values. :rfc:`7643` §3.1, in the spirit of `erratum 8472 `_ +- :meth:`~scim2_models.Resource.from_schema` no longer crashes on ``reference`` attributes missing the optional ``referenceTypes``, and reads them as :class:`~scim2_models.URI` references. + [0.6.12] - 2026-04-13 --------------------- diff --git a/samples/rfc7643-8.7.1-schema-enterprise_user.json b/samples/rfc7643-8.7.1-schema-enterprise_user.json index ad3d6f0b..9d9feb9e 100644 --- a/samples/rfc7643-8.7.1-schema-enterprise_user.json +++ b/samples/rfc7643-8.7.1-schema-enterprise_user.json @@ -86,7 +86,7 @@ "multiValued": false, "description": "The URI of the SCIM resource representing the User's manager. REQUIRED.", "required": true, - "caseExact": false, + "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" diff --git a/samples/rfc7643-8.7.1-schema-group.json b/samples/rfc7643-8.7.1-schema-group.json index 6ac48e0c..c9cbf579 100644 --- a/samples/rfc7643-8.7.1-schema-group.json +++ b/samples/rfc7643-8.7.1-schema-group.json @@ -28,7 +28,7 @@ "multiValued": false, "description": "Identifier of the member of this Group.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "immutable", "returned": "default", "uniqueness": "none" @@ -43,7 +43,7 @@ "multiValued": false, "description": "The URI corresponding to a SCIM resource that is a member of this Group.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "immutable", "returned": "default", "uniqueness": "none" diff --git a/samples/rfc7643-8.7.1-schema-user.json b/samples/rfc7643-8.7.1-schema-user.json index e8b759d8..984537c7 100644 --- a/samples/rfc7643-8.7.1-schema-user.json +++ b/samples/rfc7643-8.7.1-schema-user.json @@ -125,7 +125,7 @@ "multiValued": false, "description": "A fully qualified URL pointing to a page representing the User's online profile.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none" @@ -562,7 +562,7 @@ "multiValued": false, "description": "The identifier of the User's group.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none" @@ -576,7 +576,7 @@ "multiValued": false, "description": "The URI of the corresponding 'Group' resource to which the user belongs.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none" diff --git a/samples/rfc7643-8.7.2-schema-resource_type.json b/samples/rfc7643-8.7.2-schema-resource_type.json index de718bf6..a15adcb5 100644 --- a/samples/rfc7643-8.7.2-schema-resource_type.json +++ b/samples/rfc7643-8.7.2-schema-resource_type.json @@ -46,7 +46,7 @@ "multiValued": false, "description": "The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'.", "required": true, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "server" diff --git a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json index f7c5d1f0..dd5299cf 100644 --- a/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +++ b/samples/rfc7643-8.7.2-schema-service_provider_configuration.json @@ -13,7 +13,7 @@ "multiValued": false, "description": "An HTTP-addressable URL pointing to the service provider's human-consumable help documentation.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none" @@ -226,7 +226,7 @@ "multiValued": false, "description": "An HTTP-addressable URL pointing to the authentication scheme's specification.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none" @@ -240,7 +240,7 @@ "multiValued": false, "description": "An HTTP-addressable URL pointing to the authentication scheme's usage documentation.", "required": false, - "caseExact": false, + "caseExact": true, "mutability": "readOnly", "returned": "default", "uniqueness": "none" diff --git a/scim2_models/base.py b/scim2_models/base.py index d427673f..7e98cfc7 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -6,6 +6,7 @@ from typing import get_origin from pydantic import AliasGenerator +from pydantic import Base64Bytes from pydantic import BaseModel as PydanticBaseModel from pydantic import ConfigDict from pydantic import FieldSerializationInfo @@ -20,11 +21,13 @@ from pydantic_core import PydanticCustomError from typing_extensions import Self +from scim2_models.annotations import CaseExact from scim2_models.annotations import Mutability from scim2_models.annotations import Required from scim2_models.annotations import Returned from scim2_models.context import Context from scim2_models.exceptions import MutabilityException +from scim2_models.reference import Reference from scim2_models.utils import UNION_TYPES from scim2_models.utils import _find_field_name from scim2_models.utils import _normalize_attribute_name @@ -145,15 +148,35 @@ def get_field_annotation(cls, field_name: str, annotation_type: type) -> Any: """ field_metadata = cls.model_fields[field_name].metadata - default_value = getattr(annotation_type, "_default", None) - def annotation_type_filter(item: Any) -> bool: return isinstance(item, annotation_type) - field_annotation = next( - filter(annotation_type_filter, field_metadata), default_value - ) - return field_annotation + field_annotation = next(filter(annotation_type_filter, field_metadata), None) + if field_annotation is not None: + return field_annotation + + if annotation_type is CaseExact: + return cls._default_case_exact(field_name) + + return getattr(annotation_type, "_default", None) + + @classmethod + def _default_case_exact(cls, field_name: str) -> CaseExact: + """Return the implicit case sensitivity of a field, based on its type. + + :rfc:`RFC7643 §2.3.6 <7643#section-2.3.6>` and + :rfc:`§2.3.7 <7643#section-2.3.7>` state that binary and reference + values are case exact, whatever the schema representations of + :rfc:`§8.7 <7643#section-8.7>` say. + """ + root_type = cls.get_field_root_type(field_name) + if root_type == Base64Bytes: + return CaseExact.true + + if isclass(root_type) and issubclass(root_type, Reference): + return CaseExact.true + + return CaseExact.false @classmethod def get_field_root_type(cls, attribute_name: str) -> type | None: diff --git a/scim2_models/resources/group.py b/scim2_models/resources/group.py index fd3cfe14..dfc7b4b2 100644 --- a/scim2_models/resources/group.py +++ b/scim2_models/resources/group.py @@ -6,6 +6,7 @@ from pydantic import Field +from ..annotations import CaseExact from ..annotations import Mutability from ..annotations import Required from ..attributes import ComplexAttribute @@ -18,7 +19,7 @@ class GroupMember(ComplexAttribute): - value: Annotated[str | None, Mutability.immutable] = None + value: Annotated[str | None, Mutability.immutable, CaseExact.true] = None """Identifier of the member of this Group.""" ref: Annotated[ # type: ignore[type-arg] diff --git a/scim2_models/resources/resource_type.py b/scim2_models/resources/resource_type.py index 31a7902e..5d83037e 100644 --- a/scim2_models/resources/resource_type.py +++ b/scim2_models/resources/resource_type.py @@ -21,7 +21,6 @@ class SchemaExtension(ComplexAttribute): Reference[URI] | None, Mutability.read_only, Required.true, - CaseExact.true, ] = Field(None, alias="schema") """The URI of a schema extension.""" @@ -74,7 +73,6 @@ class ResourceType(Resource[Any]): Reference[URI] | None, Mutability.read_only, Required.true, - CaseExact.true, ] = Field(None, alias="schema") """The resource type's primary/base schema URI.""" diff --git a/scim2_models/resources/schema.py b/scim2_models/resources/schema.py index 718ec5df..c08afa2c 100644 --- a/scim2_models/resources/schema.py +++ b/scim2_models/resources/schema.py @@ -101,11 +101,13 @@ def _to_python( self, reference_types: list[str] | None = None, ) -> type: - if self.value == self.reference and reference_types is not None: + if self.value == self.reference: if reference_types == ["external"]: return Reference[External] - if reference_types == ["uri"]: + # 'referenceTypes' is not required by RFC7643 §7, and a + # reference is a URI per §2.3.7. + if not reference_types or reference_types == ["uri"]: return Reference[URI] if len(reference_types) == 1: @@ -224,7 +226,7 @@ def _to_python(self) -> tuple[Any, Any] | None: annotation = Annotated[ attr_type | None, # type: ignore self.required, - self.case_exact, + self._implicit_case_exact(), self.mutability, self.returned, self.uniqueness, @@ -240,6 +242,22 @@ def _to_python(self) -> tuple[Any, Any] | None: return annotation, field + def _implicit_case_exact(self) -> CaseExact: + """Return the case sensitivity the built field must be annotated with. + + Binary and reference values are case exact per + :rfc:`RFC7643 §2.3.6 <7643#section-2.3.6>` and + :rfc:`§2.3.7 <7643#section-2.3.7>`, unless the schema explicitly states + otherwise. + """ + if "case_exact" in self.model_fields_set: + return self.case_exact + + if self.type in (self.Type.reference, self.Type.binary): + return CaseExact.true + + return self.case_exact + def get_attribute(self, attribute_name: str) -> Optional["Attribute"]: """Find an attribute by its name.""" for sub_attribute in self.sub_attributes or []: diff --git a/scim2_models/resources/user.py b/scim2_models/resources/user.py index b57207ce..43b25e65 100644 --- a/scim2_models/resources/user.py +++ b/scim2_models/resources/user.py @@ -130,7 +130,7 @@ class Type(str, Enum): photo = "photo" thumbnail = "thumbnail" - value: Annotated[Reference[External] | None, CaseExact.true] = None + value: Reference[External] | None = None """URL of a photo of the User.""" display: str | None = None @@ -198,7 +198,7 @@ class Entitlement(ComplexAttribute): class GroupMembership(ComplexAttribute): - value: Annotated[str | None, Mutability.read_only] = None + value: Annotated[str | None, Mutability.read_only, CaseExact.true] = None """The identifier of the User's group.""" ref: Annotated[ @@ -234,7 +234,7 @@ class Role(ComplexAttribute): class X509Certificate(ComplexAttribute): - value: Annotated[Base64Bytes | None, CaseExact.true] = None + value: Base64Bytes | None = None """The value of an X.509 certificate.""" display: str | None = None diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index f9ddce8b..d5c9f3eb 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -62,7 +62,7 @@ def test_make_group_model_from_schema(load_sample): == "Identifier of the member of this Group." ) assert Members.get_field_annotation("value", Required) == Required.false - assert Members.get_field_annotation("value", CaseExact) == CaseExact.false + assert Members.get_field_annotation("value", CaseExact) == CaseExact.true assert Members.get_field_annotation("value", Mutability) == Mutability.immutable assert Members.get_field_annotation("value", Returned) == Returned.default assert Members.get_field_annotation("value", Uniqueness) == Uniqueness.none @@ -78,7 +78,7 @@ def test_make_group_model_from_schema(load_sample): ) assert Members.model_fields["ref"].serialization_alias == "$ref" assert Members.get_field_annotation("ref", Required) == Required.false - assert Members.get_field_annotation("ref", CaseExact) == CaseExact.false + assert Members.get_field_annotation("ref", CaseExact) == CaseExact.true assert Members.get_field_annotation("ref", Mutability) == Mutability.immutable assert Members.get_field_annotation("ref", Returned) == Returned.default assert Members.get_field_annotation("ref", Uniqueness) == Uniqueness.none @@ -298,7 +298,7 @@ def test_make_user_model_from_schema(load_sample): == "A fully qualified URL pointing to a page representing the User's online profile." ) assert User.get_field_annotation("profile_url", Required) == Required.false - assert User.get_field_annotation("profile_url", CaseExact) == CaseExact.false + assert User.get_field_annotation("profile_url", CaseExact) == CaseExact.true assert User.get_field_annotation("profile_url", Mutability) == Mutability.read_write assert User.get_field_annotation("profile_url", Returned) == Returned.default assert User.get_field_annotation("profile_url", Uniqueness) == Uniqueness.none @@ -851,7 +851,7 @@ def test_make_user_model_from_schema(load_sample): == "The identifier of the User's group." ) assert Groups.get_field_annotation("value", Required) == Required.false - assert Groups.get_field_annotation("value", CaseExact) == CaseExact.false + assert Groups.get_field_annotation("value", CaseExact) == CaseExact.true assert Groups.get_field_annotation("value", Mutability) == Mutability.read_only assert Groups.get_field_annotation("value", Returned) == Returned.default assert Groups.get_field_annotation("value", Uniqueness) == Uniqueness.none @@ -864,7 +864,7 @@ def test_make_user_model_from_schema(load_sample): == "The URI of the corresponding 'Group' resource to which the user belongs." ) assert Groups.get_field_annotation("ref", Required) == Required.false - assert Groups.get_field_annotation("ref", CaseExact) == CaseExact.false + assert Groups.get_field_annotation("ref", CaseExact) == CaseExact.true assert Groups.get_field_annotation("ref", Mutability) == Mutability.read_only assert Groups.get_field_annotation("ref", Returned) == Returned.default assert Groups.get_field_annotation("ref", Uniqueness) == Uniqueness.none @@ -1399,7 +1399,7 @@ def test_make_enterprise_user_model_from_schema(load_sample): == "The URI of the SCIM resource representing the User's manager. REQUIRED." ) assert Manager.get_field_annotation("ref", Required) == Required.true - assert Manager.get_field_annotation("ref", CaseExact) == CaseExact.false + assert Manager.get_field_annotation("ref", CaseExact) == CaseExact.true assert Manager.get_field_annotation("ref", Mutability) == Mutability.read_write assert Manager.get_field_annotation("ref", Returned) == Returned.default assert Manager.get_field_annotation("ref", Uniqueness) == Uniqueness.none @@ -1485,7 +1485,7 @@ def test_make_resource_type_model_from_schema(load_sample): == "The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'." ) assert ResourceType.get_field_annotation("endpoint", Required) == Required.true - assert ResourceType.get_field_annotation("endpoint", CaseExact) == CaseExact.false + assert ResourceType.get_field_annotation("endpoint", CaseExact) == CaseExact.true assert ( ResourceType.get_field_annotation("endpoint", Mutability) == Mutability.read_only @@ -1641,7 +1641,7 @@ def test_make_service_provider_config_model_from_schema(load_sample): ) assert ( ServiceProviderConfig.get_field_annotation("documentation_uri", CaseExact) - == CaseExact.false + == CaseExact.true ) assert ( ServiceProviderConfig.get_field_annotation("documentation_uri", Mutability) @@ -2048,7 +2048,7 @@ def test_make_service_provider_config_model_from_schema(load_sample): ) assert ( AuthenticationSchemes.get_field_annotation("spec_uri", CaseExact) - == CaseExact.false + == CaseExact.true ) assert ( AuthenticationSchemes.get_field_annotation("spec_uri", Mutability) @@ -2079,7 +2079,7 @@ def test_make_service_provider_config_model_from_schema(load_sample): ) assert ( AuthenticationSchemes.get_field_annotation("documentation_uri", CaseExact) - == CaseExact.false + == CaseExact.true ) assert ( AuthenticationSchemes.get_field_annotation("documentation_uri", Mutability) @@ -2730,7 +2730,7 @@ def test_make_schema_model_from_schema(load_sample): == "Identifier of the member of this Group." ) assert not obj.attributes[1].sub_attributes[0].required - assert not obj.attributes[1].sub_attributes[0].case_exact + assert obj.attributes[1].sub_attributes[0].case_exact assert obj.attributes[1].sub_attributes[0].mutability == Mutability.immutable assert obj.attributes[1].sub_attributes[0].returned == Returned.default assert obj.attributes[1].sub_attributes[0].uniqueness == Uniqueness.none @@ -2742,7 +2742,7 @@ def test_make_schema_model_from_schema(load_sample): "The URI corresponding to a SCIM resource that is a member of this Group." ) assert not obj.attributes[1].sub_attributes[1].required - assert not obj.attributes[1].sub_attributes[1].case_exact + assert obj.attributes[1].sub_attributes[1].case_exact assert obj.attributes[1].sub_attributes[1].mutability == Mutability.immutable assert obj.attributes[1].sub_attributes[1].returned == Returned.default assert obj.attributes[1].sub_attributes[1].uniqueness == Uniqueness.none @@ -2771,3 +2771,55 @@ def test_make_schema_model_from_schema(load_sample): def test_empty_attribute(): """Attributes must at least have a name to be pythonizable.""" assert Attribute()._to_python() is None + + +def _single_attribute_schema(attribute): + return Schema.model_validate( + { + "id": "urn:example:2.0:Single", + "name": "Single", + "attributes": [attribute], + } + ) + + +def test_schemas_omitting_case_exact_on_references_and_binaries(): + """Schemas that do not advertise caseExact on a binary or reference attribute get the case sensitivity of their type, as stated in RFC7643 §2.3.6 and §2.3.7.""" + schema = _single_attribute_schema( + {"name": "attr", "type": "reference", "referenceTypes": ["User"]} + ) + Model = Resource.from_schema(schema) + assert Model.get_field_annotation("attr", CaseExact) == CaseExact.true + + schema = _single_attribute_schema({"name": "attr", "type": "binary"}) + Model = Resource.from_schema(schema) + assert Model.get_field_annotation("attr", CaseExact) == CaseExact.true + + schema = _single_attribute_schema({"name": "attr", "type": "string"}) + Model = Resource.from_schema(schema) + assert Model.get_field_annotation("attr", CaseExact) == CaseExact.false + + +def test_schemas_advertising_case_insensitive_references(): + """A schema explicitly advertising a case insensitive reference is taken at its word.""" + schema = _single_attribute_schema( + { + "name": "attr", + "type": "reference", + "referenceTypes": ["User"], + "caseExact": False, + } + ) + Model = Resource.from_schema(schema) + assert Model.get_field_annotation("attr", CaseExact) == CaseExact.false + + +def test_references_without_reference_types(): + """RFC7643 §7 does not require 'referenceTypes', and a reference is a URI per RFC7643 §2.3.7.""" + for attribute in ( + {"name": "attr", "type": "reference"}, + {"name": "attr", "type": "reference", "referenceTypes": []}, + ): + Model = Resource.from_schema(_single_attribute_schema(attribute)) + assert Model.get_field_root_type("attr") == Reference[URI] + assert Model.to_schema().attributes[0].reference_types == ["uri"] diff --git a/tests/test_model_attributes.py b/tests/test_model_attributes.py index 418b88c7..6270d99a 100644 --- a/tests/test_model_attributes.py +++ b/tests/test_model_attributes.py @@ -1,7 +1,10 @@ import uuid from typing import Annotated +from pydantic import Base64Bytes + from scim2_models import URN +from scim2_models.annotations import CaseExact from scim2_models.annotations import Returned from scim2_models.attributes import ComplexAttribute from scim2_models.base import BaseModel @@ -40,6 +43,32 @@ def test_guess_root_type(): assert Sup.get_field_root_type("refunion") == Reference[Sub | User] +class CaseSensitivity(Resource): + __schema__ = URN("urn:example:2.0:CaseSensitivity") + + text: str | None = None + ref: Reference[Sub] | None = None + certificate: Base64Bytes | None = None + insensitive_ref: Annotated[Reference[Sub] | None, CaseExact.false] = None + + +def test_reference_and_binary_values_are_case_exact(): + """RFC7643 §2.3.6 and §2.3.7 state that binary and reference values are case exact, whatever the schema representations of §8.7 say.""" + assert CaseSensitivity.get_field_annotation("text", CaseExact) == CaseExact.false + assert CaseSensitivity.get_field_annotation("ref", CaseExact) == CaseExact.true + assert ( + CaseSensitivity.get_field_annotation("certificate", CaseExact) == CaseExact.true + ) + + +def test_case_exact_annotation_takes_precedence_over_the_field_type(): + """Models can declare a reference to be case insensitive.""" + assert ( + CaseSensitivity.get_field_annotation("insensitive_ref", CaseExact) + == CaseExact.false + ) + + class ReturnedModel(BaseModel): always: Annotated[str | None, Returned.always] = None never: Annotated[str | None, Returned.never] = None diff --git a/tests/test_schema.py b/tests/test_schema.py index 4c8ac6f4..6f730068 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -39,7 +39,7 @@ def test_group_schema(load_sample): == "Identifier of the member of this Group." ) assert not obj.attributes[1].sub_attributes[0].required - assert not obj.attributes[1].sub_attributes[0].case_exact + assert obj.attributes[1].sub_attributes[0].case_exact assert obj.attributes[1].sub_attributes[0].mutability == Mutability.immutable assert obj.attributes[1].sub_attributes[0].returned == Returned.default assert obj.attributes[1].sub_attributes[0].uniqueness == Uniqueness.none @@ -51,7 +51,7 @@ def test_group_schema(load_sample): "The URI corresponding to a SCIM resource that is a member of this Group." ) assert not obj.attributes[1].sub_attributes[1].required - assert not obj.attributes[1].sub_attributes[1].case_exact + assert obj.attributes[1].sub_attributes[1].case_exact assert obj.attributes[1].sub_attributes[1].mutability == Mutability.immutable assert obj.attributes[1].sub_attributes[1].returned == Returned.default assert obj.attributes[1].sub_attributes[1].uniqueness == Uniqueness.none