Skip to content

Commit 595fbea

Browse files
committed
Merge branch 'main' into cursor-based-pagination
2 parents 14b346e + b6c3bfa commit 595fbea

14 files changed

Lines changed: 425 additions & 31 deletions

‎.github/workflows/tests.yaml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,15 @@ jobs:
152152
- name: Install dependencies
153153
run: uv sync --group doc
154154
- run: uv run sphinx-build -W --keep-going -b html doc build/sphinx/html
155+
156+
all-checks:
157+
if: always()
158+
needs: [tests, coverage, minversions, style, doc]
159+
runs-on: ubuntu-24.04
160+
steps:
161+
- name: Fail if a needed job did not succeed
162+
if: >-
163+
contains(needs.*.result, 'failure')
164+
|| contains(needs.*.result, 'cancelled')
165+
|| contains(needs.*.result, 'skipped')
166+
run: exit 1

‎doc/changelog.rst‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Changelog
77
Added
88
^^^^^
99
- Support for :rfc:`RFC9865 <9865>`
10+
- :meth:`Resource.replace <scim2_models.Resource.replace>` returns whether the replacement
11+
changes the resource, the order of multi-valued entries aside. A server can keep
12+
``meta.version`` and ``meta.lastModified`` when a PUT changes nothing.
1013

1114
Changed
1215
^^^^^^^
@@ -15,13 +18,22 @@ Changed
1518
:class:`~scim2_models.InvalidCountException` from an :class:`~scim2_models.Error` carrying the
1619
matching ``scimType``, as :rfc:`RFC9865 §2.1 <9865#section-2.1>` defines them. They used to fall
1720
back to the base :class:`~scim2_models.SCIMException`.
21+
- :attr:`AttributeBinding.urn <scim2_models.AttributeBinding.urn>` and the error messages
22+
that quote it spell the attribute as the schema declares it, such as ``userName``, whatever
23+
case the path used.
1824

1925
Security
2026
^^^^^^^^
2127
- The ``password`` of a :class:`~scim2_models.User` is case-exact, so ``password eq "SECRET"``
2228
no longer matches ``secret``. RFC 7643 declares it case-insensitive, but compares passwords
2329
by salted hash (:rfc:`RFC7643 §4.1.1 <7643#section-4.1.1>`), and the rules of
2430
:rfc:`RFC7644 §5 <7644#section-5>` keep the case of passwords.
31+
- A filter or a PATCH path that nests more than 32 expressions, such as ``not(not(...))``, is
32+
refused with ``invalidFilter`` or ``invalidPath``. A deep one used to raise a
33+
:class:`RecursionError`. Parentheses and chains of ``and`` or ``or`` do not count.
34+
- Filters and paths from clients no longer grow memory without limit. Each unknown attribute
35+
name, and each spelling of a known one such as ``USERNAME``, used to stay in memory for the
36+
life of the process. Filters and paths longer than 1024 characters are no longer cached.
2537

2638
[0.9.0] - 2026-09-27
2739
--------------------

‎doc/how-to/validate-and-serialize.rst‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ the read-only attributes, and the immutable ones keep the value they were create
115115
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
116116
... )
117117
>>> replacement.replace(stored)
118+
True
118119
>>> replacement.id
119120
'2819c223-7f76-453a-919d-413861904646'
120121
>>> replacement.meta.version
@@ -128,6 +129,10 @@ The check also covers the sub-attributes of a complex attribute. In a multi-valu
128129
covers the entries the stored resource also holds, matched by their ``value``. So a replacement
129130
can add and remove entries, but cannot change an immutable sub-attribute of an entry it keeps.
130131

132+
:meth:`~scim2_models.Resource.replace` returns :data:`False` when the replacement changes
133+
nothing, the order of multi-valued entries aside. A server can then keep ``meta.version`` and
134+
``meta.lastModified`` unchanged, as for a PATCH that changes nothing.
135+
131136
Some values are not compared, so that a valid replacement is never refused. These are an entry
132137
whose ``value`` matches several entries, and an immutable reference. Per
133138
:rfc:`RFC7643 §2.4 <7643#section-2.4>`, two spellings of one URI can be equivalent.

‎doc/integrations/_examples/integrations.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,10 @@ def execute_bulk(bulk_request, location_for):
341341
# RFC7644 §3.7.3: a job performs as many changes as possible, unless
342342
# the client caps the failures it accepts with "failOnErrors".
343343
errors += 1
344-
if bulk_request.fail_on_errors and errors >= bulk_request.fail_on_errors:
344+
if (
345+
bulk_request.fail_on_errors is not None
346+
and errors >= bulk_request.fail_on_errors
347+
):
345348
break
346349

347350
return BulkResponse[User](operations=operations)

‎doc/overview.rst‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ Replace and patch a resource
161161
----------------------------
162162

163163
Validate a replacement with :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`, then use
164-
:meth:`~scim2_models.Resource.replace` with the stored resource. This checks immutable values:
164+
:meth:`~scim2_models.Resource.replace` with the stored resource. This checks immutable values,
165+
and tells whether the replacement changes the resource:
165166

166167
.. doctest::
167168

@@ -170,6 +171,7 @@ Validate a replacement with :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RE
170171
... scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
171172
... )
172173
>>> replacement.replace(user)
174+
False
173175

174176
Apply a :class:`~scim2_models.PatchOp` to make a partial update:
175177

‎scim2_models/path/grammar.py‎

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from lark import Lark
1010
from lark import Token
1111
from lark import Transformer
12+
from lark import Tree
1213
from lark import v_args
1314
from lark.exceptions import LarkError
1415
from lark.exceptions import VisitError
@@ -208,51 +209,115 @@ def null_value(self, _token: Token) -> None:
208209
return None
209210

210211

212+
_MAX_DEPTH = 32
213+
"""How many expressions a filter or a path may nest.
214+
215+
A deeper tree would exhaust the stack of the recursive visitors, and no client
216+
sends one.
217+
"""
218+
219+
_MAX_CACHED_LENGTH = 1024
220+
"""How long a filter or a path may be to stay in the parser cache.
221+
222+
Real filters and paths are much shorter. Parsing the longer ones each time
223+
bounds the memory the cache holds.
224+
"""
225+
226+
_NESTING_RULES = frozenset(
227+
{"or_expr", "and_expr", "not_expr", "value_path", "value_path_sub"}
228+
)
229+
230+
231+
def _depth(tree: Tree[Token]) -> int:
232+
"""Count the expressions nested in a parse tree, without recursion.
233+
234+
Parentheses leave no node in the tree, and a chain of ``and`` or ``or`` is a
235+
single node, so neither adds to the depth.
236+
"""
237+
depths: dict[int, int] = {}
238+
for subtree in tree.iter_subtrees():
239+
children = (
240+
depths[id(child)] for child in subtree.children if isinstance(child, Tree)
241+
)
242+
nested = max(children, default=0)
243+
depths[id(subtree)] = nested + (subtree.data in _NESTING_RULES)
244+
return depths[id(tree)]
245+
246+
211247
# One parser for both entry points: the two start rules share a grammar, and
212248
# building their tables together halves the cost of importing this module.
213249
_PARSER = Lark(_GRAMMAR, start=["filter", "path"], parser="lalr")
214250
_BUILDER = _AstBuilder()
215251

216252

217-
@lru_cache(maxsize=1024)
218253
def _parse_filter(expression: str) -> FilterNode:
219254
"""Parse a SCIM filter expression into its root node.
220255
221-
The expression is the one a ``filter`` query parameter or a
222-
``SearchRequest.filter`` carries. A syntactically invalid one raises
256+
The expression is the one a filter query parameter or a
257+
SearchRequest.filter carries. A syntactically invalid one raises
223258
InvalidFilterException.
224-
225-
>>> from scim2_models.path.grammar import _parse_filter
226-
>>> _parse_filter('userName eq "bjensen"')
227-
Comparison(attr_path=AttrPath(attr='userName', sub_attr=None, uri=None), op=<CompareOperator.eq: 'eq'>, value='bjensen')
228259
"""
260+
if len(expression) > _MAX_CACHED_LENGTH:
261+
return _build_filter(expression)
262+
return _cached_filter(expression)
263+
264+
265+
def _build_filter(expression: str) -> FilterNode:
266+
"""Parse a SCIM filter expression, without the cache."""
229267
try:
230268
tree = _PARSER.parse(expression, start="filter")
269+
except LarkError as exc:
270+
raise InvalidFilterException(
271+
filter=expression, detail=_error_detail(exc)
272+
) from exc
273+
274+
if _depth(tree) > _MAX_DEPTH:
275+
raise InvalidFilterException(
276+
filter=expression,
277+
detail=f"the filter nests more than {_MAX_DEPTH} expressions",
278+
)
279+
280+
try:
231281
return cast(FilterNode, _BUILDER.transform(tree))
232282
except LarkError as exc:
233283
raise InvalidFilterException(
234284
filter=expression, detail=_error_detail(exc)
235285
) from exc
236286

237287

238-
@lru_cache(maxsize=1024)
239288
def _parse_path(path: str) -> PathNode:
240289
"""Parse a SCIM PATCH path into an abstract syntax tree.
241290
242-
The path is the one a ``PatchOperation.path`` carries. A syntactically
291+
The path is the one a PatchOperation.path carries. A syntactically
243292
invalid one raises InvalidPathException.
244-
245-
>>> from scim2_models.path.grammar import _parse_path
246-
>>> _parse_path("name.familyName")
247-
AttrPath(attr='name', sub_attr='familyName', uri=None)
248293
"""
294+
if len(path) > _MAX_CACHED_LENGTH:
295+
return _build_path(path)
296+
return _cached_path(path)
297+
298+
299+
def _build_path(path: str) -> PathNode:
300+
"""Parse a SCIM PATCH path, without the cache."""
249301
try:
250302
tree = _PARSER.parse(path, start="path")
303+
except LarkError as exc:
304+
raise InvalidPathException(path=path, detail=_error_detail(exc)) from exc
305+
306+
if _depth(tree) > _MAX_DEPTH:
307+
raise InvalidPathException(
308+
path=path, detail=f"the path nests more than {_MAX_DEPTH} expressions"
309+
)
310+
311+
try:
251312
return cast(PathNode, _BUILDER.transform(tree))
252313
except LarkError as exc:
253314
raise InvalidPathException(path=path, detail=_error_detail(exc)) from exc
254315

255316

317+
_cached_filter = lru_cache(maxsize=1024)(_build_filter)
318+
_cached_path = lru_cache(maxsize=1024)(_build_path)
319+
320+
256321
def _error_detail(exc: LarkError) -> str:
257322
"""Build a human readable message out of a lark failure."""
258323
if isinstance(exc, VisitError):

‎scim2_models/path/resolution.py‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ..exceptions import InvalidFilterException
1717
from ..exceptions import PathNotFoundException
1818
from ..utils import _find_field_name
19+
from ..utils import _normalize_attribute_name
1920
from .expressions import ORDERING_OPERATORS
2021
from .expressions import STRING_OPERATORS
2122
from .expressions import AttrPath
@@ -251,16 +252,27 @@ def _resolve_attr_path(
251252
# cannot outlive the model it describes: a resolved attribute names the
252253
# model it was resolved on, which a cache keyed by that model would keep
253254
# alive for as long as the process runs.
254-
cache: dict[tuple[AttrPath, bool], AttributeBinding | None] | None
255+
cache: dict[tuple[str | None, str, str | None], AttributeBinding] | None
255256
cache = model.__dict__.get(_RESOLVED_ATTRS)
256257
if cache is None:
257258
cache = {}
258259
setattr(model, _RESOLVED_ATTRS, cache)
259260

260-
key = (attr_path, strict)
261-
if key not in cache:
262-
cache[key] = _resolve_attr_path_uncached(model, attr_path, strict=strict)
263-
return cache[key]
261+
# Paths come from clients. Folding their case and leaving out the unknown
262+
# ones bounds the cache to the names the model accepts, however many
263+
# spellings a client sends.
264+
key = (
265+
attr_path.uri.lower() if attr_path.uri else None,
266+
_normalize_attribute_name(attr_path.attr),
267+
_normalize_attribute_name(attr_path.sub_attr) if attr_path.sub_attr else None,
268+
)
269+
if key in cache:
270+
return cache[key]
271+
272+
binding = _resolve_attr_path_uncached(model, attr_path, strict=strict)
273+
if binding is not None:
274+
cache[key] = binding
275+
return binding
264276

265277

266278
def _resolve_attr_path_uncached(
@@ -319,7 +331,7 @@ def _resolve_attr_path_uncached(
319331
sub_field_name=sub_field_name,
320332
sub_field_type=sub_field_type,
321333
case_exact=case_exact,
322-
urn=_build_urn(target, attr_path),
334+
urn=_build_urn(target, field_name, field_type, sub_field_name),
323335
)
324336

325337

@@ -391,12 +403,21 @@ def _resolve_filter_path(
391403
raise InvalidFilterException(detail=str(exc)) from exc
392404

393405

394-
def _build_urn(model: type[BaseModel], attr_path: AttrPath) -> str:
395-
"""Build the fully qualified URN of a resolved attribute."""
396-
schema = attr_path.uri or (
397-
getattr(model, "__schema__", None) if isclass(model) else None
398-
)
399-
suffix = str(AttrPath(attr=attr_path.attr, sub_attr=attr_path.sub_attr))
406+
def _build_urn(
407+
model: type[BaseModel],
408+
field_name: str,
409+
field_type: type | None,
410+
sub_field_name: str | None,
411+
) -> str:
412+
"""Build the fully qualified URN of a resolved attribute.
413+
414+
The URN is spelled as the schema declares it, whatever case the path used.
415+
"""
416+
schema = getattr(model, "__schema__", None)
417+
suffix = model._scim_name(field_name)
418+
if sub_field_name is not None:
419+
sub_model = cast(type[BaseModel], field_type)
420+
suffix = f"{suffix}.{sub_model._scim_name(sub_field_name)}"
400421
return f"{schema}:{suffix}" if schema else suffix
401422

402423

‎scim2_models/resources/resource.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
187187
meta: Annotated[Meta | None, Mutability.read_only, Returned.default] = None
188188
"""A complex attribute containing resource metadata."""
189189

190-
def replace(self, original: Self) -> None:
190+
def replace(self, original: Self) -> bool:
191191
"""Apply :rfc:`RFC 7644 §3.5.1 <7644#section-3.5.1>` replace (PUT) semantics.
192192
193193
``readOnly`` fields are copied from *original*.
@@ -204,9 +204,13 @@ def replace(self, original: Self) -> None:
204204
<7643#section-2.4>`.
205205
206206
:param original: The original resource state to compare against.
207+
:return: :data:`True` if the replacement differs from *original*, the
208+
order of the entries of multi-valued attributes aside
209+
(:rfc:`RFC7643 §2.4 <7643#section-2.4>`), :data:`False` otherwise.
207210
:raises MutabilityException: If an immutable field value differs.
208211
"""
209212
self._apply_replace_constraints(original)
213+
return bool(_unordered(self.model_dump()) != _unordered(original.model_dump()))
210214

211215
@classmethod
212216
def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]:
@@ -439,6 +443,15 @@ def from_schema(cls, schema: "Schema") -> type["Resource[Any]"]:
439443
AnyResource = TypeVar("AnyResource", bound="Resource[Any]")
440444

441445

446+
def _unordered(value: Any) -> Any:
447+
"""Reduce a dumped value to a form that compares equal regardless of entry order."""
448+
if isinstance(value, dict):
449+
return {key: _unordered(item) for key, item in value.items()}
450+
if isinstance(value, list):
451+
return sorted((_unordered(item) for item in value), key=repr)
452+
return value
453+
454+
442455
def _dedicated_attributes(
443456
model: type[BaseModel], excluded_models: list[type[BaseModel]]
444457
) -> dict[str, Any]:

‎tests/test_doc_examples.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -950,8 +950,12 @@ def test_bulk_answers_404_for_an_operation_on_an_unknown_resource():
950950
assert missing.response.detail == "Resource does not exist."
951951

952952

953-
def test_bulk_stops_once_the_error_limit_the_client_set_is_reached():
954-
"""A client caps the failures it accepts, and the operations past that cap stay undone."""
953+
@pytest.mark.parametrize("fail_on_errors", [1, 0])
954+
def test_bulk_stops_once_the_error_limit_the_client_set_is_reached(fail_on_errors):
955+
"""A client caps the failures it accepts, and the operations past that cap stay undone.
956+
957+
A client that accepts no failure stops the job at the first one.
958+
"""
955959
from doc.integrations._examples import integrations
956960

957961
integrations.records.clear()
@@ -960,7 +964,7 @@ def test_bulk_stops_once_the_error_limit_the_client_set_is_reached():
960964
request = BulkRequest[User].model_validate(
961965
{
962966
"schemas": [BULK_REQUEST_SCHEMA],
963-
"failOnErrors": 1,
967+
"failOnErrors": fail_on_errors,
964968
"Operations": [
965969
{
966970
"method": "POST",

0 commit comments

Comments
 (0)