Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -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 <scim2_models.Error.from_validation_error>` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`.
- :meth:`PatchOp.patch <scim2_models.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 ``<Exception>.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 ``<Exception>.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 <scim2_models.Error.from_validation_error>` to convert Pydantic :class:`~pydantic.ValidationError` to SCIM :class:`~scim2_models.Error`.
- :meth:`PatchOp.patch <scim2_models.PatchOp.patch>` auto-excludes other ``primary`` values when setting one to ``True``. :issue:`116`

[0.5.2] - 2026-01-22
--------------------

Expand Down
10 changes: 0 additions & 10 deletions doc/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
4 changes: 4 additions & 0 deletions scim2_models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +88,7 @@
"EnterpriseUser",
"Entitlement",
"Error",
"External",
"ExternalReference",
"Extension",
"Filter",
Expand Down Expand Up @@ -130,6 +133,7 @@
"TooManyException",
"Uniqueness",
"UniquenessException",
"URI",
"URIReference",
"URN",
"User",
Expand Down
186 changes: 138 additions & 48 deletions scim2_models/reference.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 8 additions & 5 deletions scim2_models/resources/enterprise_user.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import TYPE_CHECKING
from typing import Annotated
from typing import Literal

from pydantic import Field

Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions scim2_models/resources/group.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
3 changes: 1 addition & 2 deletions scim2_models/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Loading
Loading