diff --git a/doc/changelog.rst b/doc/changelog.rst index 86a7374..96b329a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,31 +1,44 @@ Changelog ========= -[0.x.x] - Unreleased +[0.6.0] - Unreleased -------------------- Added ^^^^^ - Resources define their schema URN with a ``__schema__`` classvar instead of a ``schemas`` default value. :issue:`110` -- Validation that the base schema is present in ``schemas`` during SCIM context validation. -- Validation that extension schemas are known during SCIM context validation. -- Introduce SCIM exceptions hierarchy (:class:`~scim2_models.SCIMException` and subclasses) corresponding to RFC 7644 error types. :issue:`103` -- :meth:`Error.from_validation_error ` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`. -- :meth:`PatchOp.patch ` auto-excludes other ``primary`` values when setting one to ``True``. :issue:`116` +- :class:`~scim2_models.External` and :class:`~scim2_models.URI` marker classes for reference types. Changed ^^^^^^^ - Introduce a :class:`~scim2_models.Path` object to handle paths. :issue:`111` +- :class:`~scim2_models.Reference` type parameters simplified: -Deprecated -^^^^^^^^^^ -- Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead. -- ``Error.make_*_error()`` methods are deprecated. Use ``.to_error()`` instead. + - ``Reference[ExternalReference]`` → ``Reference[External]`` + - ``Reference[URIReference]`` → ``Reference[URI]`` + - ``Reference[Literal["User"]]`` → ``Reference["User"]`` + - ``Reference[Literal["User"] | Literal["Group"]]`` → ``Reference[Union["User", "Group"]]`` + +- :class:`~scim2_models.Reference` now validates URI format for ``External`` and ``URI`` types. +- :class:`~scim2_models.Reference` inherits from ``str`` directly instead of ``UserString``. Fixed ^^^^^ - Only allow one primary complex attribute value to be true. :issue:`10` +Deprecated +^^^^^^^^^^ +- Defining ``schemas`` with a default value is deprecated. Use ``__schema__ = URN("...")`` instead. +- ``Error.make_*_error()`` methods are deprecated. Use ``.to_error()`` instead. +- ``Reference[Literal["X"]]`` syntax is deprecated. Use ``Reference["X"]`` instead. Will be removed in 0.7.0. +- ``ExternalReference`` alias is deprecated. Use :class:`~scim2_models.External` instead. Will be removed in 0.7.0. +- ``URIReference`` alias is deprecated. Use :class:`~scim2_models.URI` instead. Will be removed in 0.7.0. +- Validation that the base schema is present in ``schemas`` during SCIM context validation. +- Validation that extension schemas are known during SCIM context validation. +- Introduce SCIM exceptions hierarchy (:class:`~scim2_models.SCIMException` and subclasses) corresponding to RFC 7644 error types. :issue:`103` +- :meth:`Error.from_validation_error ` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`. +- :meth:`PatchOp.patch ` auto-excludes other ``primary`` values when setting one to ``True``. :issue:`116` + [0.5.2] - 2026-01-22 -------------------- diff --git a/doc/reference.rst b/doc/reference.rst index 664cbaf..9311561 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -8,15 +8,5 @@ This page presents all the models provided by scim2-models. Type bound to any subclass of :class:`~scim2_models.Resource`. -.. data:: scim2_models.ExternalReference - :type: typing.Type - - External reference type as described in :rfc:`RFC7643 §7 <7643#section-7>`. - -.. data:: scim2_models.URIReference - :type: typing.Type - - URI reference type as described in :rfc:`RFC7643 §7 <7643#section-7>`. - .. automodule:: scim2_models :members: diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 7da28c5..7923afd 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -30,6 +30,8 @@ from .messages.search_request import SearchRequest from .path import URN from .path import Path +from .reference import URI +from .reference import External from .reference import ExternalReference from .reference import Reference from .reference import URIReference @@ -86,6 +88,7 @@ "EnterpriseUser", "Entitlement", "Error", + "External", "ExternalReference", "Extension", "Filter", @@ -130,6 +133,7 @@ "TooManyException", "Uniqueness", "UniquenessException", + "URI", "URIReference", "URN", "User", diff --git a/scim2_models/reference.py b/scim2_models/reference.py index 3b8171e..d51da25 100644 --- a/scim2_models/reference.py +++ b/scim2_models/reference.py @@ -1,80 +1,170 @@ -from collections import UserString +import warnings from typing import Any from typing import Generic -from typing import NewType +from typing import Literal from typing import TypeVar from typing import get_args from typing import get_origin from pydantic import GetCoreSchemaHandler +from pydantic_core import Url from pydantic_core import core_schema from .utils import UNION_TYPES ReferenceTypes = TypeVar("ReferenceTypes") -URIReference = NewType("URIReference", str) -ExternalReference = NewType("ExternalReference", str) +class External: + """Marker for external references per :rfc:`RFC7643 §7 <7643#section-7>`. -class Reference(UserString, Generic[ReferenceTypes]): + Use with :class:`Reference` to type external resource URLs (photos, websites):: + + profile_url: Reference[External] | None = None + """ + + +class URI: + """Marker for URI references per :rfc:`RFC7643 §7 <7643#section-7>`. + + Use with :class:`Reference` to type URI identifiers (schema URNs, endpoints):: + + endpoint: Reference[URI] | None = None + """ + + +class ExternalReference: + """Deprecated. Use :class:`External` instead.""" + + +class URIReference: + """Deprecated. Use :class:`URI` instead.""" + + +class Reference(str, Generic[ReferenceTypes]): """Reference type as defined in :rfc:`RFC7643 §2.3.7 <7643#section-2.3.7>`. References can take different type parameters: - - Any :class:`~scim2_models.Resource` subtype, or :class:`~typing.ForwardRef` of a Resource subtype, or :data:`~typing.Union` of those, - - :data:`~scim2_models.ExternalReference` - - :data:`~scim2_models.URIReference` - - Examples - -------- + - :class:`~scim2_models.External` for external resources (photos, websites) + - :class:`~scim2_models.URI` for URI identifiers (schema URNs, endpoints) + - String forward references for SCIM resource types (``"User"``, ``"Group"``) + - Resource classes directly if imports allow - .. code-block:: python + Examples:: class Foobar(Resource): - bff: Reference[User] - managers: Reference[Union["User", "Group"]] - photo: Reference[ExternalReference] - website: Reference[URIReference] - + photo: Reference[External] | None = None + website: Reference[URI] | None = None + manager: Reference["User"] | None = None + members: Reference[Union["User", "Group"]] | None = None + + .. versionchanged:: 0.6.0 + + - ``Reference[ExternalReference]`` becomes ``Reference[External]`` + - ``Reference[URIReference]`` becomes ``Reference[URI]`` + - ``Reference[Literal["User"]]`` becomes ``Reference["User"]`` + - ``Reference[Literal["User"] | Literal["Group"]]`` becomes + ``Reference[Union["User", "Group"]]`` """ + __slots__ = () + __reference_types__: tuple[str, ...] = () + _cache: dict[tuple[str, ...], type["Reference[Any]"]] = {} + + def __class_getitem__(cls, item: Any) -> type["Reference[Any]"]: + if get_origin(item) in UNION_TYPES: + items = get_args(item) + else: + items = (item,) + + type_strings = tuple(_to_type_string(i) for i in items) + + if type_strings in cls._cache: + return cls._cache[type_strings] + + class TypedReference(cls): # type: ignore[valid-type,misc] + __reference_types__ = type_strings + + TypedReference.__name__ = f"Reference[{' | '.join(type_strings)}]" + TypedReference.__qualname__ = TypedReference.__name__ + cls._cache[type_strings] = TypedReference + return TypedReference + @classmethod def __get_pydantic_core_schema__( cls, - _source: type[Any], - _handler: GetCoreSchemaHandler, + source_type: type[Any], + handler: GetCoreSchemaHandler, ) -> core_schema.CoreSchema: - return core_schema.no_info_after_validator_function( - cls._validate, - core_schema.union_schema( - [core_schema.str_schema(), core_schema.is_instance_schema(cls)] - ), + ref_types = getattr(source_type, "__reference_types__", ()) + + def validate(value: Any) -> "Reference[Any]": + if not isinstance(value, str): + raise ValueError(f"Expected string, got {type(value).__name__}") + if "external" in ref_types or "uri" in ref_types: + _validate_uri(value) + return source_type(value) # type: ignore[no-any-return] + + return core_schema.no_info_plain_validator_function( + validate, + serialization=core_schema.plain_serializer_function_ser_schema(str), ) @classmethod - def _validate(cls, input_value: Any, /) -> str: - return str(input_value) - - @classmethod - def get_types(cls, type_annotation: Any) -> list[str]: - """Get reference types from a type annotation. - - :param type_annotation: Type annotation to extract reference types from - :return: List of reference type strings - """ - first_arg = get_args(type_annotation)[0] - types = ( - get_args(first_arg) if get_origin(first_arg) in UNION_TYPES else [first_arg] + def get_scim_reference_types(cls) -> list[str]: + """Return referenceTypes for SCIM schema generation.""" + return list(cls.__reference_types__) + + +def _to_type_string(item: Any) -> str: + """Convert any type parameter to its SCIM referenceType string.""" + if item is Any: + return "uri" + if item is External: + return "external" + if item is ExternalReference: + warnings.warn( + "Reference[ExternalReference] is deprecated, " + "use Reference[External] instead. Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=4, ) - - def serialize_ref_type(ref_type: Any) -> str: - if ref_type == URIReference: - return "uri" - - elif ref_type == ExternalReference: - return "external" - - return str(get_args(ref_type)[0]) - - return list(map(serialize_ref_type, types)) + return "external" + if item is URI: + return "uri" + if item is URIReference: + warnings.warn( + "Reference[URIReference] is deprecated, " + "use Reference[URI] instead. Will be removed in 0.7.0.", + DeprecationWarning, + stacklevel=4, + ) + return "uri" + if isinstance(item, str): + return item + if isinstance(item, type): + return item.__name__ + if hasattr(item, "__forward_arg__"): + return item.__forward_arg__ # type: ignore[no-any-return] + # Support Literal["User"] for backwards compatibility + if get_origin(item) is Literal: + value = get_args(item)[0] + warnings.warn( + f'Reference[Literal["{value}"]] is deprecated, ' + f'use Reference["{value}"] instead. Will be removed in 0.7.0.', + DeprecationWarning, + stacklevel=4, + ) + return value # type: ignore[no-any-return] + raise TypeError(f"Invalid reference type: {item!r}") + + +def _validate_uri(value: str) -> None: + """Validate URI format, allowing relative URIs per RFC 7643.""" + if value.startswith("/"): + return + try: + Url(value) + except Exception as e: + raise ValueError(f"Invalid URI: {value}") from e diff --git a/scim2_models/resources/enterprise_user.py b/scim2_models/resources/enterprise_user.py index acca4fd..59a95a8 100644 --- a/scim2_models/resources/enterprise_user.py +++ b/scim2_models/resources/enterprise_user.py @@ -1,5 +1,5 @@ +from typing import TYPE_CHECKING from typing import Annotated -from typing import Literal from pydantic import Field @@ -10,15 +10,18 @@ from ..reference import Reference from .resource import Extension +if TYPE_CHECKING: + from .user import User + class Manager(ComplexAttribute): value: Annotated[str | None, Required.true] = None """The id of the SCIM resource representing the User's manager.""" - ref: Annotated[Reference[Literal["User"]] | None, Required.true] = Field( - None, - serialization_alias="$ref", - ) + ref: Annotated[ # type: ignore[type-arg] + Reference["User"] | None, + Required.true, + ] = Field(None, serialization_alias="$ref") """The URI of the SCIM resource representing the User's manager.""" display_name: Annotated[str | None, Mutability.read_only] = None diff --git a/scim2_models/resources/group.py b/scim2_models/resources/group.py index 116f4be..d520aab 100644 --- a/scim2_models/resources/group.py +++ b/scim2_models/resources/group.py @@ -1,7 +1,8 @@ +from typing import TYPE_CHECKING from typing import Annotated from typing import Any from typing import ClassVar -from typing import Literal +from typing import Union from pydantic import Field @@ -11,13 +12,16 @@ from ..reference import Reference from .resource import Resource +if TYPE_CHECKING: + from .user import User + class GroupMember(ComplexAttribute): value: Annotated[str | None, Mutability.immutable] = None """Identifier of the member of this Group.""" - ref: Annotated[ - Reference[Literal["User"] | Literal["Group"]] | None, + ref: Annotated[ # type: ignore[type-arg] + Reference[Union["User", "Group"]] | None, Mutability.immutable, ] = Field(None, serialization_alias="$ref") """The reference URI of a target resource, if the attribute is a diff --git a/scim2_models/resources/resource.py b/scim2_models/resources/resource.py index 479016a..f38c37b 100644 --- a/scim2_models/resources/resource.py +++ b/scim2_models/resources/resource.py @@ -30,7 +30,6 @@ from ..context import Context from ..exceptions import InvalidPathException from ..path import Path -from ..reference import Reference from ..scim_object import ScimObject from ..utils import UNION_TYPES from ..utils import _normalize_attribute_name @@ -545,7 +544,7 @@ def _model_attribute_to_scim_attribute( returned=model.get_field_annotation(attribute_name, Returned), uniqueness=model.get_field_annotation(attribute_name, Uniqueness), sub_attributes=sub_attributes, - reference_types=Reference.get_types(root_type) + reference_types=root_type.get_scim_reference_types() # type: ignore[attr-defined] if attribute_type == Attribute.Type.reference else None, ) diff --git a/scim2_models/resources/resource_type.py b/scim2_models/resources/resource_type.py index ce3b785..f8848c4 100644 --- a/scim2_models/resources/resource_type.py +++ b/scim2_models/resources/resource_type.py @@ -10,14 +10,14 @@ from ..annotations import Returned from ..attributes import ComplexAttribute from ..path import URN +from ..reference import URI from ..reference import Reference -from ..reference import URIReference from .resource import Resource class SchemaExtension(ComplexAttribute): schema_: Annotated[ - Reference[URIReference] | None, + Reference[URI] | None, Mutability.read_only, Required.true, CaseExact.true, @@ -57,14 +57,14 @@ class ResourceType(Resource[Any]): This is often the same value as the "name" attribute. """ - endpoint: Annotated[ - Reference[URIReference] | None, Mutability.read_only, Required.true - ] = None + endpoint: Annotated[Reference[URI] | None, Mutability.read_only, Required.true] = ( + None + ) """The resource type's HTTP-addressable endpoint relative to the Base URL, e.g., '/Users'.""" schema_: Annotated[ - Reference[URIReference] | None, + Reference[URI] | None, Mutability.read_only, Required.true, CaseExact.true, @@ -90,11 +90,11 @@ def from_resource(cls, resource_model: type[Resource[Any]]) -> Self: id=name, name=name, description=name, - endpoint=Reference[URIReference](f"/{name}s"), - schema_=Reference[URIReference](schema), + endpoint=Reference[URI](f"/{name}s"), + schema_=Reference[URI](schema), schema_extensions=[ SchemaExtension( - schema_=Reference[URIReference](extension.__schema__), + schema_=Reference[URI](extension.__schema__), required=False, ) for extension in extensions diff --git a/scim2_models/resources/schema.py b/scim2_models/resources/schema.py index 2a7522a..4674b0f 100644 --- a/scim2_models/resources/schema.py +++ b/scim2_models/resources/schema.py @@ -4,11 +4,9 @@ from typing import Annotated from typing import Any from typing import List # noqa : UP005,UP035 -from typing import Literal from typing import Optional from typing import TypeVar from typing import Union -from typing import get_origin from pydantic import Base64Bytes from pydantic import Field @@ -28,9 +26,9 @@ from ..base import BaseModel from ..constants import RESERVED_WORDS from ..path import URN -from ..reference import ExternalReference +from ..reference import URI +from ..reference import External from ..reference import Reference -from ..reference import URIReference from ..utils import _normalize_attribute_name from .resource import Resource @@ -101,13 +99,14 @@ def _to_python( ) -> type: if self.value == self.reference and reference_types is not None: if reference_types == ["external"]: - return Reference[ExternalReference] + return Reference[External] if reference_types == ["uri"]: - return Reference[URIReference] + return Reference[URI] - types = tuple(Literal[t] for t in reference_types) - return Reference[Union[types]] # type: ignore # noqa: UP007 + if len(reference_types) == 1: + return Reference[reference_types[0]] # type: ignore[valid-type] + return Reference[Union[tuple(reference_types)]] # type: ignore[misc,return-value] # noqa: UP007 attr_types = { self.string: str, @@ -122,7 +121,7 @@ def _to_python( @classmethod def from_python(cls, pytype: type) -> "Attribute.Type": - if get_origin(pytype) == Reference: + if isinstance(pytype, type) and issubclass(pytype, Reference): return cls.reference if pytype and is_complex_attribute(pytype): diff --git a/scim2_models/resources/service_provider_config.py b/scim2_models/resources/service_provider_config.py index 54d7677..d29352f 100644 --- a/scim2_models/resources/service_provider_config.py +++ b/scim2_models/resources/service_provider_config.py @@ -10,7 +10,7 @@ from ..annotations import Uniqueness from ..attributes import ComplexAttribute from ..path import URN -from ..reference import ExternalReference +from ..reference import External from ..reference import Reference from .resource import Resource @@ -74,15 +74,13 @@ class Type(str, Enum): description: Annotated[str | None, Mutability.read_only, Required.true] = None """A description of the authentication scheme.""" - spec_uri: Annotated[Reference[ExternalReference] | None, Mutability.read_only] = ( - None - ) + spec_uri: Annotated[Reference[External] | None, Mutability.read_only] = None """An HTTP-addressable URL pointing to the authentication scheme's specification.""" - documentation_uri: Annotated[ - Reference[ExternalReference] | None, Mutability.read_only - ] = None + documentation_uri: Annotated[Reference[External] | None, Mutability.read_only] = ( + None + ) """An HTTP-addressable URL pointing to the authentication scheme's usage documentation.""" @@ -105,9 +103,9 @@ class ServiceProviderConfig(Resource[Any]): # resources, the "id" attribute is not required for the service # provider configuration resource - documentation_uri: Annotated[ - Reference[ExternalReference] | None, Mutability.read_only - ] = None + documentation_uri: Annotated[Reference[External] | None, Mutability.read_only] = ( + None + ) """An HTTP-addressable URL pointing to the service provider's human- consumable help documentation.""" diff --git a/scim2_models/resources/user.py b/scim2_models/resources/user.py index 2a08a23..f43af52 100644 --- a/scim2_models/resources/user.py +++ b/scim2_models/resources/user.py @@ -1,7 +1,8 @@ from enum import Enum +from typing import TYPE_CHECKING from typing import Annotated from typing import ClassVar -from typing import Literal +from typing import Union from pydantic import Base64Bytes from pydantic import EmailStr @@ -14,11 +15,14 @@ from ..annotations import Uniqueness from ..attributes import ComplexAttribute from ..path import URN -from ..reference import ExternalReference +from ..reference import External from ..reference import Reference from .resource import AnyExtension from .resource import Resource +if TYPE_CHECKING: + from .group import Group + class Name(ComplexAttribute): formatted: str | None = None @@ -127,7 +131,7 @@ class Type(str, Enum): photo = "photo" thumbnail = "thumbnail" - value: Annotated[Reference[ExternalReference] | None, CaseExact.true] = None + value: Annotated[Reference[External] | None, CaseExact.true] = None """URL of a photo of the User.""" display: str | None = None @@ -198,8 +202,8 @@ class GroupMembership(ComplexAttribute): value: Annotated[str | None, Mutability.read_only] = None """The identifier of the User's group.""" - ref: Annotated[ - Reference[Literal["User"] | Literal["Group"]] | None, + ref: Annotated[ # type: ignore[type-arg] + Reference[Union["User", "Group"]] | None, Mutability.read_only, ] = Field(None, serialization_alias="$ref") """The reference URI of a target resource, if the attribute is a @@ -264,7 +268,7 @@ class User(Resource[AnyExtension]): """The casual way to address the user in real life, e.g., 'Bob' or 'Bobby' instead of 'Robert'.""" - profile_url: Reference[ExternalReference] | None = None + profile_url: Reference[External] | None = None """A fully qualified URL pointing to a page representing the User's online profile.""" diff --git a/tests/test_reference.py b/tests/test_reference.py index 1a94711..46cfd24 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -1,22 +1,37 @@ -"""Test Reference constructor functionality.""" +"""Test Reference type functionality.""" from typing import Annotated +from typing import Any +from typing import Literal +from typing import Union import pytest +from scim2_models import URI +from scim2_models import External +from scim2_models import ExternalReference +from scim2_models import Reference +from scim2_models import URIReference from scim2_models.annotations import Required from scim2_models.base import BaseModel -from scim2_models.reference import ExternalReference -from scim2_models.reference import Reference -from scim2_models.reference import URIReference + + +class User: + """Dummy class for testing forward references.""" + + +class Group: + """Dummy class for testing forward references.""" class ReferenceTestModel(BaseModel): """Test model with Reference fields.""" schemas: Annotated[list[str], Required.true] = ["urn:example:test"] - uri_ref: Reference[URIReference] | None = None - ext_ref: Reference[ExternalReference] | None = None + uri_ref: Reference[URI] | None = None + ext_ref: Reference[External] | None = None + resource_ref: Reference["User"] | None = None + multi_ref: Reference[Union["User", "Group"]] | None = None def test_reference_uri_string_assignment(): @@ -27,8 +42,8 @@ def test_reference_uri_string_assignment(): def test_reference_uri_constructor(): - """Test that URI references accept Reference[URIReference] constructor.""" - model = ReferenceTestModel(uri_ref=Reference[URIReference]("https://example.com")) + """Test that URI references accept Reference[URI] constructor.""" + model = ReferenceTestModel(uri_ref=Reference[URI]("https://example.com")) assert model.uri_ref == "https://example.com" assert isinstance(model.uri_ref, str) @@ -41,10 +56,8 @@ def test_reference_external_string_assignment(): def test_reference_external_constructor(): - """Test that external references accept Reference[ExternalReference] constructor.""" - model = ReferenceTestModel( - ext_ref=Reference[ExternalReference]("https://external.com") - ) + """Test that external references accept Reference[External] constructor.""" + model = ReferenceTestModel(ext_ref=Reference[External]("https://external.com")) assert model.ext_ref == "https://external.com" assert isinstance(model.ext_ref, str) @@ -58,7 +71,7 @@ def test_reference_plain_constructor(): def test_reference_serialization(): """Test that Reference instances serialize correctly.""" - ref = Reference[URIReference]("https://example.com") + ref = Reference[URI]("https://example.com") model = ReferenceTestModel(uri_ref=ref) dumped = model.model_dump() @@ -66,14 +79,138 @@ def test_reference_serialization(): def test_reference_validation_error(): - """Test that invalid values still raise validation errors.""" + """Test that invalid values raise validation errors.""" with pytest.raises(ValueError): - ReferenceTestModel(uri_ref=123) # Invalid type should still fail + ReferenceTestModel(uri_ref=123) -def test_reference_userstring_behavior(): - """Test that Reference still behaves like UserString.""" - ref = Reference[URIReference]("https://example.com") +def test_reference_string_behavior(): + """Test that Reference behaves like a string.""" + ref = Reference[URI]("https://example.com") assert str(ref) == "https://example.com" assert ref == "https://example.com" assert ref.startswith("https://") + + +def test_reference_resource_type(): + """Test Reference with resource type string.""" + model = ReferenceTestModel(resource_ref="https://example.com/Users/123") + assert model.resource_ref == "https://example.com/Users/123" + + +def test_reference_union_types(): + """Test Reference with union of resource types.""" + model = ReferenceTestModel(multi_ref="https://example.com/Groups/456") + assert model.multi_ref == "https://example.com/Groups/456" + + +def test_reference_get_scim_reference_types_external(): + """Test get_scim_reference_types returns 'external'.""" + ref_type = Reference[External] + assert ref_type.get_scim_reference_types() == ["external"] + + +def test_reference_get_scim_reference_types_uri(): + """Test get_scim_reference_types returns 'uri'.""" + ref_type = Reference[URI] + assert ref_type.get_scim_reference_types() == ["uri"] + + +def test_reference_get_scim_reference_types_resource(): + """Test get_scim_reference_types returns resource name.""" + ref_type = Reference["User"] + assert ref_type.get_scim_reference_types() == ["User"] + + +def test_reference_get_scim_reference_types_union(): + """Test get_scim_reference_types returns multiple types.""" + ref_type = Reference[Union["User", "Group"]] + assert ref_type.get_scim_reference_types() == ["User", "Group"] + + +def test_reference_uri_validation_valid(): + """Test URI validation accepts valid URIs.""" + model = ReferenceTestModel(uri_ref="https://example.com/path") + assert model.uri_ref == "https://example.com/path" + + +def test_reference_uri_validation_relative(): + """Test URI validation accepts relative URIs.""" + model = ReferenceTestModel(uri_ref="/Users/123") + assert model.uri_ref == "/Users/123" + + +def test_reference_uri_validation_invalid(): + """Test URI validation rejects invalid URIs.""" + with pytest.raises(ValueError, match="Invalid URI"): + ReferenceTestModel(uri_ref="not a valid uri") + + +def test_reference_class_caching(): + """Test that Reference subclasses are cached.""" + ref1 = Reference[External] + ref2 = Reference[External] + assert ref1 is ref2 + + +def test_reference_class_name(): + """Test that Reference subclass has descriptive name.""" + ref_type = Reference[Union["User", "Group"]] + assert ref_type.__name__ == "Reference[User | Group]" + + +# Deprecation warnings tests + + +def test_external_reference_deprecation_warning(): + """Test that ExternalReference emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match="Reference\\[ExternalReference\\]"): + Reference[ExternalReference] + + +def test_uri_reference_deprecation_warning(): + """Test that URIReference emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match="Reference\\[URIReference\\]"): + Reference[URIReference] + + +def test_literal_reference_deprecation_warning(): + """Test that Literal type parameter emits a deprecation warning.""" + with pytest.warns(DeprecationWarning, match='Reference\\[Literal\\["User"\\]\\]'): + Reference[Literal["User"]] + + +def test_deprecated_external_reference_still_works(): + """Test that deprecated ExternalReference still produces valid Reference.""" + with pytest.warns(DeprecationWarning): + ref_type = Reference[ExternalReference] + + assert ref_type.get_scim_reference_types() == ["external"] + + +def test_deprecated_uri_reference_still_works(): + """Test that deprecated URIReference still produces valid Reference.""" + with pytest.warns(DeprecationWarning): + ref_type = Reference[URIReference] + + assert ref_type.get_scim_reference_types() == ["uri"] + + +def test_deprecated_literal_still_works(): + """Test that deprecated Literal syntax still produces valid Reference.""" + with pytest.warns(DeprecationWarning): + ref_type = Reference[Literal["User"]] + + assert ref_type.get_scim_reference_types() == ["User"] + + +def test_reference_any_type(): + """Test that Reference[Any] is treated as URI reference.""" + ref_type = Reference[Any] + assert ref_type.get_scim_reference_types() == ["uri"] + + +def test_reference_invalid_type_raises_error(): + """Test that invalid type parameter raises TypeError.""" + with pytest.raises(TypeError, match="Invalid reference type"): + Reference[123]