Skip to content

Commit e8a21d2

Browse files
committed
feat: build a patch from two resources
1 parent 5af640d commit e8a21d2

6 files changed

Lines changed: 759 additions & 0 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ Added
4444
:class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and
4545
``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one,
4646
so a server answering ``POST /.search`` passes the request it received. :issue:`141`
47+
- :meth:`~scim2_models.PatchOp.build_from` builds the patch turning one resource state into
48+
another. Only the attributes the wanted state names take part in the comparison, so what a peer
49+
maintains and the caller does not model survives the modification — which is what a PATCH
50+
offers over a PUT. See :doc:`how-to/build-a-patch`. :issue:`104`
4751
- lark is a new dependency.
4852

4953
Changed

‎doc/explanation/patch.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,5 +168,17 @@ model does not declare. Table 9 lists ``invalidFilter`` as applying to a "PATCH
168168
so it answers what goes wrong between the brackets. That covers an unknown sub-attribute, a
169169
comparison the attribute cannot take, and a selection over an attribute holding a single value.
170170

171+
Building a patch rather than applying one
172+
-----------------------------------------
173+
174+
An application holding both the state a peer has and the state it should have does not have to
175+
spell the operations out. :meth:`~scim2_models.PatchOp.build_from` compares the two states and
176+
builds them, restricted to the attributes the wanted state names, so what the peer maintains and
177+
the application does not model is left alone.
178+
179+
What that builder can express follows from this page. A multi-valued attribute is replaced as a
180+
whole, since :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives its entries no identity to match one
181+
state against the other. See :doc:`../how-to/build-a-patch`.
182+
171183
To inspect or change a resource directly with the same path syntax, outside a PATCH request, use
172184
:doc:`../how-to/access-resource-values`.

‎doc/how-to/build-a-patch.rst‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
Build a patch from two resource states
2+
======================================
3+
4+
Use this guide when an application holds the state a peer is believed to have and the state it
5+
should have, and must send the modification. :meth:`~scim2_models.PatchOp.build_from` compares the
6+
two and returns the :class:`~scim2_models.PatchOp` that closes the gap.
7+
8+
Build the patch
9+
---------------
10+
11+
Pass the state the peer holds first, then the state it should hold:
12+
13+
.. doctest::
14+
15+
>>> from scim2_models import Name, PatchOp, User
16+
>>> distant = User(user_name="bjensen", name=Name(given_name="Barbara", family_name="Jensen"))
17+
>>> wanted = User(user_name="bjensen", name=Name(given_name="Babs", family_name="Jensen"))
18+
>>> patch = PatchOp.build_from(distant, wanted)
19+
>>> patch.model_dump()["Operations"]
20+
[{'op': 'replace', 'path': 'name.givenName', 'value': 'Babs'}]
21+
22+
A complex attribute is compared sub-attribute by sub-attribute, and each one gets its own path.
23+
Targeting ``name`` as a whole would replace it entirely and drop what the operation does not
24+
carry.
25+
26+
Leave alone what the wanted state does not name
27+
-----------------------------------------------
28+
29+
Only the attributes the wanted state names take part in the comparison. This is what separates a
30+
patch from the :meth:`~scim2_models.Resource.replace` it stands for: an attribute the peer
31+
maintains and the application does not model survives the modification.
32+
33+
.. doctest::
34+
35+
>>> distant = User(user_name="bjensen", nick_name="Barb", title="CEO")
36+
>>> wanted = User(user_name="bjensen", nick_name="Babs")
37+
>>> patch = PatchOp.build_from(distant, wanted)
38+
>>> patch.model_dump()["Operations"]
39+
[{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}]
40+
41+
Naming an attribute with no value says the opposite. ``title=None`` reads as "clear the title",
42+
where an unnamed ``title`` reads as "leave it alone":
43+
44+
.. doctest::
45+
46+
>>> wanted = User(user_name="bjensen", title=None)
47+
>>> patch = PatchOp.build_from(distant, wanted)
48+
>>> patch.model_dump()["Operations"]
49+
[{'op': 'remove', 'path': 'title'}]
50+
51+
The same rule reaches sub-attributes, and an extension is named by its schema URN:
52+
53+
.. doctest::
54+
55+
>>> from scim2_models import EnterpriseUser
56+
>>> distant = User[EnterpriseUser](user_name="bjensen")
57+
>>> distant[EnterpriseUser] = EnterpriseUser(department="Tour")
58+
>>> wanted = User[EnterpriseUser](user_name="bjensen")
59+
>>> wanted[EnterpriseUser] = EnterpriseUser(department="Chess")
60+
>>> patch = PatchOp.build_from(distant, wanted)
61+
>>> str(patch.operations[0].path)
62+
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department'
63+
64+
Send nothing when nothing changed
65+
---------------------------------
66+
67+
Two states that agree have no patch to describe: an ``Operations`` array must hold at least one
68+
operation, per :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. :meth:`~scim2_models.PatchOp.build_from`
69+
returns :data:`None`, so an application tests it before sending a request:
70+
71+
.. doctest::
72+
73+
>>> PatchOp.build_from(wanted, wanted) is None
74+
True
75+
76+
Know how collections are compared
77+
---------------------------------
78+
79+
A multi-valued attribute is replaced as a whole. Only the sub-attributes the wanted entries name
80+
decide whether it changed, so the sub-attributes the peer alone maintains do not read as a
81+
difference:
82+
83+
.. doctest::
84+
85+
>>> from scim2_models import Email
86+
>>> distant = User(emails=[Email(value="barb@example.com", type="work", primary=True)])
87+
>>> wanted = User(emails=[Email(value="barb@example.com")])
88+
>>> PatchOp.build_from(distant, wanted) is None
89+
True
90+
91+
When the collection does change, the whole of it is replaced and the peer's own sub-attributes go
92+
with it:
93+
94+
.. doctest::
95+
96+
>>> wanted = User(emails=[Email(value="babs@example.com")])
97+
>>> patch = PatchOp.build_from(distant, wanted)
98+
>>> patch.model_dump()["Operations"]
99+
[{'op': 'replace', 'path': 'emails', 'value': [{'value': 'babs@example.com'}]}]
100+
101+
The builder cannot do better: :rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries of a
102+
multi-valued attribute no identity, so nothing distinguishes an entry that changed from an entry
103+
that was removed and another that was added. An application that knows how to identify its own
104+
entries writes those operations itself, targeting a sub-attribute through a filter such as
105+
``emails[type eq "work"].value``.
106+
107+
Read what the patch never carries
108+
---------------------------------
109+
110+
A ``readOnly`` attribute is left out however much the two states differ:
111+
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` forbids a client to modify one, and naming it would
112+
make the patch invalid. That covers :attr:`~scim2_models.Resource.id`,
113+
:attr:`~scim2_models.Resource.meta` and :attr:`~scim2_models.User.groups`, along with the
114+
``readOnly`` sub-attributes of a complex attribute.
115+
116+
An ``immutable`` attribute that holds no value yet is added, which
117+
:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows. One that already holds a value cannot be
118+
modified, and :meth:`~scim2_models.PatchOp.build_from` raises a
119+
:class:`~scim2_models.MutabilityException` rather than building a request the peer must refuse.
120+
121+
An attribute a server never returns, such as :attr:`~scim2_models.User.password`, reads as unset on
122+
the side of the peer. Every patch built from a state naming it carries it again.

‎doc/how-to/index.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ends where an application resumes its own work, and assumes the :doc:`../overvie
88
:maxdepth: 1
99

1010
access-resource-values
11+
build-a-patch
1112
build-filters
1213
define-custom-models
1314
describe-a-scim-service

‎scim2_models/messages/patch_op.py‎

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from collections.abc import Iterator
12
from enum import Enum
23
from inspect import isclass
34
from typing import Annotated
45
from typing import Any
56
from typing import Generic
67
from typing import TypeVar
8+
from typing import cast
79

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

8183

84+
_ENVELOPE_FIELDS = frozenset({"schemas"})
85+
"""Fields that carry the payload rather than the state it describes."""
86+
87+
88+
def _attribute_name(model: type[BaseModel], field_name: str) -> str:
89+
"""Return the SCIM spelling of a field, as a path segment."""
90+
return model.model_fields[field_name].serialization_alias or field_name
91+
92+
93+
def _asserted_sub_attributes(entries: Any) -> set[str]:
94+
"""Return the sub-attributes the entries of a wanted state name."""
95+
asserted: set[str] = set()
96+
for entry in entries or []:
97+
if isinstance(entry, BaseModel):
98+
asserted |= entry.model_fields_set
99+
return asserted
100+
101+
102+
def _projection(entries: Any, asserted: set[str]) -> list[Any]:
103+
"""Reduce the entries of a multi-valued attribute to what is worth comparing.
104+
105+
:rfc:`RFC7643 §2.4 <7643#section-2.4>` gives no significance to the order of
106+
a multi-valued attribute, so the projections are sorted before comparison.
107+
"""
108+
projected = [
109+
tuple(sorted((name, getattr(entry, name, None)) for name in asserted))
110+
if isinstance(entry, BaseModel)
111+
else entry
112+
for entry in entries or []
113+
]
114+
return sorted(projected, key=repr)
115+
116+
117+
def _operation(
118+
path: str, old: Any, new: Any, mutability: Mutability | None
119+
) -> tuple["PatchOperation.Op", str, Any]:
120+
"""Return the operation writing *new* where the current state holds *old*.
121+
122+
Called once a difference is established. :rfc:`RFC7644 §3.5.2.3
123+
<7644#section-3.5.2.3>` has a service provider treat a ``replace`` on an
124+
unset target as an ``add``, so a single operation covers both. An immutable
125+
attribute is the exception: :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` lets
126+
a client add a value to one that had none, and nothing else.
127+
"""
128+
if mutability == Mutability.immutable:
129+
if old is not None:
130+
raise MutabilityException(
131+
attribute=path, mutability="immutable", operation="replace"
132+
)
133+
return PatchOperation.Op.add, path, new
134+
135+
if new is None or new == []:
136+
return PatchOperation.Op.remove, path, None
137+
138+
return PatchOperation.Op.replace_, path, new
139+
140+
141+
def _diff_multi_valued(
142+
path: str, old: Any, new: Any, mutability: Mutability | None
143+
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
144+
"""Diff a multi-valued attribute, which is replaced as a whole.
145+
146+
Only the sub-attributes the wanted entries name take part in the
147+
comparison, so the sub-attributes the peer alone maintains do not read as a
148+
difference. When the collection does change it is replaced entirely:
149+
:rfc:`RFC7643 §2.4 <7643#section-2.4>` gives the entries no identity, so an
150+
entry that changed cannot be told from a removed one and an added one.
151+
"""
152+
asserted = _asserted_sub_attributes(new)
153+
if _projection(old, asserted) == _projection(new, asserted):
154+
return
155+
156+
yield _operation(path, old, new, mutability)
157+
158+
159+
def _diff_sub_object(
160+
prefix: str,
161+
path: str,
162+
old: Any,
163+
new: Any,
164+
mutability: Mutability | None,
165+
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
166+
"""Diff a complex attribute or an extension, one sub-attribute at a time."""
167+
if new is not None:
168+
yield from _diff(old, new, prefix)
169+
return
170+
171+
if old is not None:
172+
yield _operation(path, old, None, mutability)
173+
174+
175+
def _diff(
176+
before: Any, after: Any, prefix: str = ""
177+
) -> Iterator[tuple["PatchOperation.Op", str, Any]]:
178+
"""Yield the operations turning *before* into *after*.
179+
180+
Only the attributes *after* names are candidates: what a wanted state never
181+
mentions is left to the peer. Attributes are visited in declaration order,
182+
so a diff is reproducible.
183+
"""
184+
model = type(after)
185+
info = model.__scim_info__
186+
for field_name in model.model_fields:
187+
if field_name not in after.model_fields_set:
188+
continue
189+
190+
if field_name in _ENVELOPE_FIELDS:
191+
continue
192+
193+
mutability = model.get_field_annotation(field_name, Mutability)
194+
if mutability == Mutability.read_only:
195+
continue
196+
197+
old = getattr(before, field_name, None) if before is not None else None
198+
new = getattr(after, field_name, None)
199+
path = f"{prefix}{_attribute_name(model, field_name)}"
200+
201+
if model.get_field_multiplicity(field_name):
202+
yield from _diff_multi_valued(path, old, new, mutability)
203+
204+
elif field_name in info.extensions:
205+
urn = info.attribute_urns[field_name]
206+
yield from _diff_sub_object(f"{urn}:", urn, old, new, mutability)
207+
208+
elif field_name in info.complex_fields:
209+
yield from _diff_sub_object(f"{path}.", path, old, new, mutability)
210+
211+
elif old != new:
212+
yield _operation(path, old, new, mutability)
213+
214+
82215
class PatchOperation(ComplexAttribute, Generic[ResourceT]):
83216
class Op(str, Enum):
84217
replace_ = "replace"
@@ -362,6 +495,56 @@ def validate_operations(self, info: ValidationInfo) -> Self:
362495

363496
return self
364497

498+
@classmethod
499+
def build_from(
500+
cls, before: ResourceT, after: ResourceT
501+
) -> "PatchOp[ResourceT] | None":
502+
"""Build the patch turning a resource state into another one.
503+
504+
Only the attributes *after* names take part in the comparison: what a
505+
wanted state never mentions is left to the peer, which is what
506+
distinguishes a patch from the :meth:`~scim2_models.Resource.replace`
507+
it stands for. An attribute named with no value is removed, as
508+
``title=None`` reads as "clear the title" where an unnamed ``title``
509+
reads as "leave it alone".
510+
511+
A multi-valued attribute is replaced as a whole, and only the
512+
sub-attributes the wanted entries name decide whether it changed.
513+
Read-only attributes never appear in the patch.
514+
515+
>>> from scim2_models import PatchOp, User
516+
>>> patch = PatchOp.build_from(User(nick_name="Barb"), User(nick_name="Babs"))
517+
>>> patch.model_dump()["Operations"]
518+
[{'op': 'replace', 'path': 'nickName', 'value': 'Babs'}]
519+
520+
:param before: The state the peer is believed to hold.
521+
:param after: The state the peer should hold.
522+
:return: The patch to send, or :data:`None` when the two states agree.
523+
:raises MutabilityException: If an immutable attribute already holding a
524+
value would be modified.
525+
:raises TypeError: If the two states are not of the same resource type.
526+
"""
527+
if type(before) is not type(after):
528+
raise TypeError(
529+
"Cannot compare two states of different types: "
530+
f"{type(before).__name__} and {type(after).__name__}"
531+
)
532+
533+
# Subscripted through the call the syntax stands for: mypy reads the
534+
# index of a generic as a type, not as a value.
535+
model = type(after)
536+
operation_class: Any = PatchOperation.__class_getitem__(model)
537+
path_class = Path.__class_getitem__(model)
538+
operations = [
539+
operation_class(op=op, path=path_class(path), value=value)
540+
for op, path, value in _diff(before, after)
541+
]
542+
if not operations:
543+
return None
544+
545+
patch_class = PatchOp.__class_getitem__(model)
546+
return cast("PatchOp[ResourceT]", patch_class(operations=operations))
547+
365548
def patch(self, resource: ResourceT, scim_policy: ScimPolicy | None = None) -> bool:
366549
"""Apply all PATCH operations to the given SCIM resource in sequence.
367550

0 commit comments

Comments
 (0)