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
4 changes: 4 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Added
:class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and
``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one,
so a server answering ``POST /.search`` passes the request it received. :issue:`141`
- :meth:`~scim2_models.PatchOp.build_from` builds the patch turning one resource state into
another. Only the attributes the wanted state names take part in the comparison, so what a peer
maintains and the caller does not model survives the modification — which is what a PATCH
offers over a PUT. See :doc:`how-to/build-a-patch`. :issue:`104`
- lark is a new dependency.

Changed
Expand Down
12 changes: 12 additions & 0 deletions doc/explanation/patch.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,17 @@ model does not declare. Table 9 lists ``invalidFilter`` as applying to a "PATCH
so it answers what goes wrong between the brackets. That covers an unknown sub-attribute, a
comparison the attribute cannot take, and a selection over an attribute holding a single value.

Building a patch rather than applying one
-----------------------------------------

An application holding both the state a peer has and the state it should have does not have to
spell the operations out. :meth:`~scim2_models.PatchOp.build_from` compares the two states and
builds them, restricted to the attributes the wanted state names, so what the peer maintains and
the application does not model is left alone.

What that builder can express follows from this page. A multi-valued attribute is replaced as a
whole, since :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives its entries no identity to match one
state against the other. See :doc:`../how-to/build-a-patch`.

To inspect or change a resource directly with the same path syntax, outside a PATCH request, use
:doc:`../how-to/access-resource-values`.
122 changes: 122 additions & 0 deletions doc/how-to/build-a-patch.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
Build a patch from two resource states
======================================

Use this guide when an application holds the state a peer is believed to have and the state it
should have, and must send the modification. :meth:`~scim2_models.PatchOp.build_from` compares the
two and returns the :class:`~scim2_models.PatchOp` that closes the gap.

Build the patch
---------------

Pass the state the peer holds first, then the state it should hold:

.. doctest::

>>> from scim2_models import Name, PatchOp, User
>>> distant = User(user_name="bjensen", name=Name(given_name="Barbara", family_name="Jensen"))
>>> wanted = User(user_name="bjensen", name=Name(given_name="Babs", family_name="Jensen"))
>>> patch = PatchOp.build_from(distant, wanted)
>>> patch.model_dump()["Operations"]
[{'op': 'replace', 'path': 'name.givenName', 'value': 'Babs'}]

A complex attribute is compared sub-attribute by sub-attribute, and each one gets its own path.
Targeting ``name`` as a whole would replace it entirely and drop what the operation does not
carry.

Leave alone what the wanted state does not name
-----------------------------------------------

Only the attributes the wanted state names take part in the comparison. This is what separates a
patch from the :meth:`~scim2_models.Resource.replace` it stands for: an attribute the peer
maintains and the application does not model survives the modification.

.. doctest::

>>> distant = User(user_name="bjensen", nick_name="Barb", title="CEO")
>>> wanted = User(user_name="bjensen", nick_name="Babs")
>>> patch = PatchOp.build_from(distant, wanted)
>>> patch.model_dump()["Operations"]
[{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}]

Naming an attribute with no value says the opposite. ``title=None`` reads as "clear the title",
where an unnamed ``title`` reads as "leave it alone":

.. doctest::

>>> wanted = User(user_name="bjensen", title=None)
>>> patch = PatchOp.build_from(distant, wanted)
>>> patch.model_dump()["Operations"]
[{'op': 'remove', 'path': 'title'}]

The same rule reaches sub-attributes, and an extension is named by its schema URN:

.. doctest::

>>> from scim2_models import EnterpriseUser
>>> distant = User[EnterpriseUser](user_name="bjensen")
>>> distant[EnterpriseUser] = EnterpriseUser(department="Tour")
>>> wanted = User[EnterpriseUser](user_name="bjensen")
>>> wanted[EnterpriseUser] = EnterpriseUser(department="Chess")
>>> patch = PatchOp.build_from(distant, wanted)
>>> str(patch.operations[0].path)
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department'

Send nothing when nothing changed
---------------------------------

Two states that agree have no patch to describe: an ``Operations`` array must hold at least one
operation, per :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. :meth:`~scim2_models.PatchOp.build_from`
returns :data:`None`, so an application tests it before sending a request:

.. doctest::

>>> PatchOp.build_from(wanted, wanted) is None
True

Know how collections are compared
---------------------------------

A multi-valued attribute is replaced as a whole. Only the sub-attributes the wanted entries name
decide whether it changed, so the sub-attributes the peer alone maintains do not read as a
difference:

.. doctest::

>>> from scim2_models import Email
>>> distant = User(emails=[Email(value="barb@example.com", type="work", primary=True)])
>>> wanted = User(emails=[Email(value="barb@example.com")])
>>> PatchOp.build_from(distant, wanted) is None
True

When the collection does change, the whole of it is replaced and the peer's own sub-attributes go
with it:

.. doctest::

>>> wanted = User(emails=[Email(value="babs@example.com")])
>>> patch = PatchOp.build_from(distant, wanted)
>>> patch.model_dump()["Operations"]
[{'op': 'replace', 'path': 'emails', 'value': [{'value': 'babs@example.com'}]}]

The builder cannot do better: :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries of a
multi-valued attribute no identity, so nothing distinguishes an entry that changed from an entry
that was removed and another that was added. An application that knows how to identify its own
entries writes those operations itself, targeting a sub-attribute through a filter such as
``emails[type eq "work"].value``.

Read what the patch never carries
---------------------------------

A ``readOnly`` attribute is left out however much the two states differ:
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` forbids a client to modify one, and naming it would
make the patch invalid. That covers :attr:`~scim2_models.Resource.id`,
:attr:`~scim2_models.Resource.meta` and :attr:`~scim2_models.User.groups`, along with the
``readOnly`` sub-attributes of a complex attribute.

An ``immutable`` attribute that holds no value yet is added, which
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows. One that already holds a value cannot be
modified, and :meth:`~scim2_models.PatchOp.build_from` raises a
:class:`~scim2_models.MutabilityException` rather than building a request the peer must refuse.

An attribute a server never returns, such as :attr:`~scim2_models.User.password`, reads as unset on
the side of the peer. Every patch built from a state naming it carries it again.
1 change: 1 addition & 0 deletions doc/how-to/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie
:maxdepth: 1

access-resource-values
build-a-patch
build-filters
define-custom-models
describe-a-scim-service
Expand Down
183 changes: 183 additions & 0 deletions scim2_models/messages/patch_op.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from collections.abc import Iterator
from enum import Enum
from inspect import isclass
from typing import Annotated
from typing import Any
from typing import Generic
from typing import TypeVar
from typing import cast

from pydantic import BaseModel as PydanticBaseModel
from pydantic import Field
Expand Down Expand Up @@ -79,6 +81,137 @@ def _resolved_field(resource_class: type[BaseModel], attr_name: str) -> str | No
return _find_field_name(resource_class, attr_name)


_ENVELOPE_FIELDS = frozenset({"schemas"})
"""Fields that carry the payload rather than the state it describes."""


def _attribute_name(model: type[BaseModel], field_name: str) -> str:
"""Return the SCIM spelling of a field, as a path segment."""
return model.model_fields[field_name].serialization_alias or field_name


def _asserted_sub_attributes(entries: Any) -> set[str]:
"""Return the sub-attributes the entries of a wanted state name."""
asserted: set[str] = set()
for entry in entries or []:
if isinstance(entry, BaseModel):
asserted |= entry.model_fields_set
return asserted


def _projection(entries: Any, asserted: set[str]) -> list[Any]:
"""Reduce the entries of a multi-valued attribute to what is worth comparing.

:rfc:`RFC7643 §2.4 <7643#section-2.4>` gives no significance to the order of
a multi-valued attribute, so the projections are sorted before comparison.
"""
projected = [
tuple(sorted((name, getattr(entry, name, None)) for name in asserted))
if isinstance(entry, BaseModel)
else entry
for entry in entries or []
]
return sorted(projected, key=repr)


def _operation(
path: str, old: Any, new: Any, mutability: Mutability | None
) -> tuple["PatchOperation.Op", str, Any]:
"""Return the operation writing *new* where the current state holds *old*.

Called once a difference is established. :rfc:`RFC7644 §3.5.2.3
<7644#section-3.5.2.3>` has a service provider treat a ``replace`` on an
unset target as an ``add``, so a single operation covers both. An immutable
attribute is the exception: :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` lets
a client add a value to one that had none, and nothing else.
"""
if mutability == Mutability.immutable:
if old is not None:
raise MutabilityException(
attribute=path, mutability="immutable", operation="replace"
)
return PatchOperation.Op.add, path, new

if new is None or new == []:
return PatchOperation.Op.remove, path, None

return PatchOperation.Op.replace_, path, new


def _diff_multi_valued(
path: str, old: Any, new: Any, mutability: Mutability | None
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
"""Diff a multi-valued attribute, which is replaced as a whole.

Only the sub-attributes the wanted entries name take part in the
comparison, so the sub-attributes the peer alone maintains do not read as a
difference. When the collection does change it is replaced entirely:
:rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries no identity, so an
entry that changed cannot be told from a removed one and an added one.
"""
asserted = _asserted_sub_attributes(new)
if _projection(old, asserted) == _projection(new, asserted):
return

yield _operation(path, old, new, mutability)


def _diff_sub_object(
prefix: str,
path: str,
old: Any,
new: Any,
mutability: Mutability | None,
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
"""Diff a complex attribute or an extension, one sub-attribute at a time."""
if new is not None:
yield from _diff(old, new, prefix)
return

if old is not None:
yield _operation(path, old, None, mutability)


def _diff(
before: Any, after: Any, prefix: str = ""
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
"""Yield the operations turning *before* into *after*.

Only the attributes *after* names are candidates: what a wanted state never
mentions is left to the peer. Attributes are visited in declaration order,
so a diff is reproducible.
"""
model = type(after)
info = model.__scim_info__
for field_name in model.model_fields:
if field_name not in after.model_fields_set:
continue

if field_name in _ENVELOPE_FIELDS:
continue

mutability = model.get_field_annotation(field_name, Mutability)
if mutability == Mutability.read_only:
continue

old = getattr(before, field_name, None) if before is not None else None
new = getattr(after, field_name, None)
path = f"{prefix}{_attribute_name(model, field_name)}"

if model.get_field_multiplicity(field_name):
yield from _diff_multi_valued(path, old, new, mutability)

elif field_name in info.extensions:
urn = info.attribute_urns[field_name]
yield from _diff_sub_object(f"{urn}:", urn, old, new, mutability)

elif field_name in info.complex_fields:
yield from _diff_sub_object(f"{path}.", path, old, new, mutability)

elif old != new:
yield _operation(path, old, new, mutability)


class PatchOperation(ComplexAttribute, Generic[ResourceT]):
class Op(str, Enum):
replace_ = "replace"
Expand Down Expand Up @@ -362,6 +495,56 @@ def validate_operations(self, info: ValidationInfo) -> Self:

return self

@classmethod
def build_from(
cls, before: ResourceT, after: ResourceT
) -> "PatchOp[ResourceT] | None":
"""Build the patch turning a resource state into another one.

Only the attributes *after* names take part in the comparison: what a
wanted state never mentions is left to the peer, which is what
distinguishes a patch from the :meth:`~scim2_models.Resource.replace`
it stands for. An attribute named with no value is removed, as
``title=None`` reads as "clear the title" where an unnamed ``title``
reads as "leave it alone".

A multi-valued attribute is replaced as a whole, and only the
sub-attributes the wanted entries name decide whether it changed.
Read-only attributes never appear in the patch.

>>> from scim2_models import PatchOp, User
>>> patch = PatchOp.build_from(User(nick_name="Barb"), User(nick_name="Babs"))
>>> patch.model_dump()["Operations"]
[{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}]

:param before: The state the peer is believed to hold.
:param after: The state the peer should hold.
:return: The patch to send, or :data:`None` when the two states agree.
:raises MutabilityException: If an immutable attribute already holding a
value would be modified.
:raises TypeError: If the two states are not of the same resource type.
"""
if type(before) is not type(after):
raise TypeError(
"Cannot compare two states of different types: "
f"{type(before).__name__} and {type(after).__name__}"
)

# Subscripted through the call the syntax stands for: mypy reads the
# index of a generic as a type, not as a value.
model = type(after)
operation_class: Any = PatchOperation.__class_getitem__(model)
path_class = Path.__class_getitem__(model)
operations = [
operation_class(op=op, path=path_class(path), value=value)
for op, path, value in _diff(before, after)
]
if not operations:
return None

patch_class = PatchOp.__class_getitem__(model)
return cast("PatchOp[ResourceT]", patch_class(operations=operations))

def patch(self, resource: ResourceT, scim_policy: ScimPolicy | None = None) -> bool:
"""Apply all PATCH operations to the given SCIM resource in sequence.

Expand Down
Loading
Loading