Skip to content

Commit c6eabb1

Browse files
committed
refactor: let Path.resolve alone answer for the attribute a path designates
Path carried six properties reading the attribute it designates, written before AttributeBinding existed and with its words turned around: path.model named the type holding the sub-attribute where the binding names the one holding the head. Five of them go, the binding carrying each under a target_ name, and get_annotation joins it. Path.model keeps the one answer no binding gives, the model a resource root or a bare schema URN designates, and answers None for a path that names an attribute.
1 parent f3e2db3 commit c6eabb1

12 files changed

Lines changed: 257 additions & 458 deletions

‎doc/changelog.rst‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ Added
3030

3131
Changed
3232
^^^^^^^
33+
- :attr:`Path.model <scim2_models.Path.model>` answers the model a path designates when it names
34+
no attribute: the bound model for the resource root, the resource or the extension for a bare
35+
schema URN, and :data:`None` for a path naming an attribute. It used to answer the type holding
36+
the attribute, which :meth:`Path.resolve <scim2_models.Path.resolve>` now does through
37+
:class:`~scim2_models.AttributeBinding`.
3338
- :attr:`SearchRequest.sort_by <scim2_models.SearchRequest.sort_by>` is resolved against the
3439
model a parameterised request names, and an attribute none of its resource types declares
3540
answers ``invalidPath`` at validation time instead of being carried to the endpoint. Where an
@@ -44,6 +49,12 @@ Changed
4449

4550
Removed
4651
^^^^^^^
52+
- ``Path.field_name``, ``Path.field_type``, ``Path.is_multivalued``, ``Path.get_annotation`` and
53+
``Path.urn``. They read the attribute a path designates with the words of
54+
:class:`~scim2_models.AttributeBinding` turned around, ``field_name`` naming the sub-attribute
55+
where the binding names the head. :meth:`Path.resolve <scim2_models.Path.resolve>` answers the
56+
binding, which carries them as ``target_field_name``, ``target_type``,
57+
``target_is_multivalued``, :meth:`~scim2_models.AttributeBinding.get_annotation` and ``urn``.
4758
- ``Path.is_prefix_of`` and ``Path.has_prefix``. They compared the text of two paths, which
4859
anything between brackets defeated, and answered differently from the comparison
4960
:attr:`~scim2_models.ResponseParameters.attributes` and

‎doc/guides/_examples/integrations.py‎

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from scim2_models import Bulk
1010
from scim2_models import ChangePassword
1111
from scim2_models import ComplexAttribute
12-
from scim2_models import CaseExact
1312
from scim2_models import ETag
1413
from scim2_models import Filter
1514
from scim2_models import InvalidPathException
@@ -23,6 +22,7 @@
2322
from scim2_models import Sort
2423
from scim2_models import UniquenessException
2524
from scim2_models import User
25+
from scim2_models.path import attribute_host
2626

2727
# -- storage-start --
2828
records = {}
@@ -73,19 +73,19 @@ def sort_resources(resources, sort_by, sort_order=None):
7373
:param sort_order: The ``sortOrder`` query parameter, ascending by default.
7474
:raises InvalidPathException: If the attribute is unknown.
7575
"""
76-
if sort_by.field_name is None:
76+
resolved = sort_by.resolve()
77+
if resolved is None:
7778
raise InvalidPathException(
7879
path=str(sort_by), detail=f"Cannot sort on {sort_by!r}"
7980
)
8081

81-
# "String type attributes are case insensitive by default, unless the
82-
# attribute type is defined as a case-exact string."
83-
case_exact = sort_by.model.get_field_annotation(sort_by.field_name, CaseExact)
8482
descending = sort_order == SearchRequest.SortOrder.descending
8583

8684
def key(resource):
87-
value = sort_value(resource, sort_by)
88-
if isinstance(value, str) and not case_exact:
85+
value = sort_value(resource, resolved)
86+
# "String type attributes are case insensitive by default, unless the
87+
# attribute type is defined as a case-exact string."
88+
if isinstance(value, str) and not resolved.case_exact:
8989
value = value.casefold()
9090
# "if there is no data for the specified sortBy value, they are sorted
9191
# via the sortOrder parameter, i.e., they are ordered last if ascending
@@ -95,23 +95,21 @@ def key(resource):
9595
return sorted(resources, key=key, reverse=descending)
9696

9797

98-
def sort_value(resource, sort_by):
98+
def sort_value(resource, resolved):
9999
"""Return the single value a resource is ordered by.
100100
101101
A path crossing a multi-valued attribute designates the sub-attribute of
102102
every entry, where an order needs one value per resource, so the entry is
103103
picked first and the sub-attribute read from it.
104104
105105
:param resource: The resource to read.
106-
:param sort_by: The ``sortBy`` query parameter, resolved by its request.
106+
:param resolved: The attribute the ``sortBy`` designates.
107107
"""
108-
sub_field_name = sort_by.field_name if len(sort_by.parts) > 1 else None
109-
attribute = sort_by
110-
if sub_field_name is not None:
111-
attribute = type(sort_by)(str(sort_by).rsplit(".", 1)[0])
112-
value = attribute.get(resource, strict=False)
108+
host = attribute_host(resource, resolved)
109+
value = None if host is None else getattr(host, resolved.field_name, None)
110+
sub_field_name = resolved.sub_field_name
113111

114-
if attribute.is_multivalued:
112+
if resolved.is_multivalued:
115113
entries = value or []
116114
# "resources are sorted by the value of the primary attribute, if any,
117115
# or else the first value in the list, if any."

‎doc/guides/index.rst‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ Ordering and paging collections
7575
A collection endpoint answers the ``sortBy``, ``sortOrder``, ``startIndex`` and ``count``
7676
parameters of :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`. Naming the resource type the endpoint
7777
serves, with :class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], resolves
78-
:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below reads
79-
:attr:`Path.field_name <scim2_models.Path.field_name>` instead of the attribute name a client
78+
:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below works from
79+
the :class:`~scim2_models.AttributeBinding` it designates instead of from the name a client
8080
spelled.
8181

8282
:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides the order in three ways, and the helper

‎scim2_models/messages/search_request.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _resolvable_sort_by(cls, value: Any) -> Any:
9292
# Parameterising the request names the resource types the endpoint
9393
# serves, which is what makes an attribute none of them declares a
9494
# client error rather than something to resolve later.
95-
if value is not None and value.models and value.field_name is None:
95+
if value is not None and value.models and value.resolve() is None:
9696
raise InvalidPathException(
9797
path=str(value), detail=f"Cannot sort on {str(value)!r}"
9898
).as_pydantic_error()

‎scim2_models/path/path.py‎

Lines changed: 46 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,33 @@ def _scim_name(model: type[BaseModel], field_name: str) -> str:
5757

5858

5959
class Path(_BoundToModels, _Expression, Generic[ResourceT]):
60+
"""A SCIM attribute path, as defined at :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`.
61+
62+
A path *is* the string it was built from, and designates one of three
63+
things:
64+
65+
- a model, when the path is ``""`` (the resource root) or a bare schema
66+
URN, accessible with the :attr:`model` property;
67+
- an attribute, such as ``userName`` or ``name.familyName``, accessible
68+
with the :meth:`resolve` method as an :class:`~scim2_models.AttributeBinding`;
69+
- some values of a multi-valued attribute, when the path selects them as in
70+
``emails[type eq "work"]``: the :attr:`value_filter` property holds the
71+
filter, and the :meth:`resolve` method still answers for the attribute
72+
they belong to.
73+
74+
Syntax is checked on creation. Resolving attribute names requires a model
75+
to resolve them against, bound with a parameterised type such as
76+
``Path[User]``. The :meth:`get`, :meth:`set` and :meth:`delete` methods
77+
read and write a resource through the path.
78+
79+
On Python 3.14, a path can be written as a t-string, whose interpolated
80+
values are quoted as :meth:`ScimFilter.quote <scim2_models.ScimFilter.quote>`
81+
does. Another path, or a :class:`~scim2_models.ScimFilter`, is inserted
82+
as it stands::
83+
84+
Path[Group](t"members[value eq {member_id}]")
85+
"""
86+
6087
_ast: "PathNode | None" = None
6188
"""The parsed path, kept once :attr:`ast` has computed it."""
6289

@@ -234,13 +261,24 @@ def value_filter(self) -> "FilterNode | None":
234261
value_path = self._as_value_path()
235262
return None if value_path is None else value_path.val_filter
236263

237-
def _designated_model(self) -> type[BaseModel] | None:
238-
"""Return the model this path designates when it names no attribute.
264+
@property
265+
def model(self) -> type[BaseModel] | None:
266+
"""The model this path designates, when it names no attribute.
267+
268+
The resource root designates the bound model, and a bare schema URN
269+
the resource or the extension it is the schema of. A path naming an
270+
attribute designates a model through none of them, and answers
271+
:data:`None`: :meth:`resolve` tells which attribute it is.
239272
240-
The resource root designates the bound model, and a bare schema URN the
241-
model it is the schema of, among the bound models and their extensions.
242-
Neither can go through attribute resolution, since neither names an
243-
attribute.
273+
>>> from scim2_models import EnterpriseUser, Path, User
274+
>>> Path[User]("").model is User
275+
True
276+
>>> Path[User[EnterpriseUser]](
277+
... EnterpriseUser.__schema__
278+
... ).model is EnterpriseUser
279+
True
280+
>>> Path[User]("userName").model is None
281+
True
244282
"""
245283
if not self.__scim_models__:
246284
return None
@@ -256,18 +294,16 @@ def _designated_model(self) -> type[BaseModel] | None:
256294
def resolve(self) -> "AttributeBinding | None":
257295
"""Bind this path to the attribute it designates on the bound model.
258296
259-
This is the single resolution the model-aware properties are built on.
260-
261297
:returns: The resolved attribute, or :data:`None` when the path is
262298
unbound, designates a model rather than an attribute, or names an
263299
attribute the model does not declare.
264300
"""
265301
model = self._resolving_model()
266-
if model is None or self._designated_model() is not None:
302+
if model is None or self.model is not None:
267303
return None
268304

269305
# A path that designates no attribute has already returned above,
270-
# since _designated_model answers for the resource root.
306+
# since the model property answers for the resource root.
271307
designated = self._designated_attr_path()
272308
assert designated is not None
273309

@@ -283,93 +319,6 @@ def models(self) -> tuple[type[BaseModel], ...]:
283319
"""
284320
return self.__scim_models__
285321

286-
@property
287-
def model(self) -> type[BaseModel] | None:
288-
"""The model holding the attribute this path designates.
289-
290-
Requires the Path to be bound to a model type via ``Path[Model]``.
291-
Returns None if the path is unbound or invalid.
292-
293-
For "name.familyName" on Path[User], returns Name.
294-
For "userName" on Path[User], returns User.
295-
"""
296-
if (designated := self._designated_model()) is not None:
297-
return designated
298-
299-
resolved = self.resolve()
300-
return resolved.target_model if resolved is not None else None
301-
302-
@property
303-
def field_name(self) -> str | None:
304-
"""The Python attribute name (snake_case) for this path.
305-
306-
Requires the Path to be bound to a model type via ``Path[Model]``.
307-
Returns None if the path is unbound, invalid, or designates a model
308-
rather than one of its attributes.
309-
310-
For "name.familyName" on Path[User], returns "family_name".
311-
For "userName" on Path[User], returns "user_name".
312-
"""
313-
resolved = self.resolve()
314-
return resolved.target_field_name if resolved is not None else None
315-
316-
@property
317-
def field_type(self) -> type | None:
318-
"""The Python type of the field this path points to.
319-
320-
Annotated types are unwrapped, so a ``binary`` attribute declared as
321-
``Base64Bytes`` reports :class:`bytes`.
322-
323-
For "userName" on Path[User], returns str.
324-
For "name" on Path[User], returns Name.
325-
For "emails" on Path[User], returns Email.
326-
"""
327-
resolved = self.resolve()
328-
return resolved.target_type if resolved is not None else None
329-
330-
@property
331-
def is_multivalued(self) -> bool | None:
332-
"""Whether this path points to a multi-valued attribute.
333-
334-
For "emails" on Path[User], returns True.
335-
For "emails.value" on Path[User], returns False, as the path
336-
designates one value per entry.
337-
"""
338-
resolved = self.resolve()
339-
return resolved.target_is_multivalued if resolved is not None else None
340-
341-
def get_annotation(self, annotation_type: type) -> Any:
342-
"""Get annotation value for this path's field.
343-
344-
:param annotation_type: The annotation class (e.g., Required, Mutability).
345-
:returns: The annotation value, or None when the path designates no
346-
attribute.
347-
348-
For "userName" on Path[User] with Required, returns Required.true.
349-
"""
350-
resolved = self.resolve()
351-
if resolved is None or resolved.target_model is None:
352-
return None
353-
return resolved.target_model.get_field_annotation(
354-
resolved.target_field_name, annotation_type
355-
)
356-
357-
@property
358-
def urn(self) -> str | None:
359-
"""The fully qualified URN for this path.
360-
361-
Requires the Path to be bound to a model type via ``Path[Model]``.
362-
Returns None if the path is unbound or invalid.
363-
364-
For "userName" on Path[User], returns
365-
"urn:ietf:params:scim:schemas:core:2.0:User:userName".
366-
"""
367-
if (designated := self._designated_model()) is not None:
368-
return getattr(designated, "__schema__", None) or None
369-
370-
resolved = self.resolve()
371-
return resolved.urn if resolved is not None else None
372-
373322
def _as_value_path(self) -> ValuePath | None:
374323
"""Normalise a value-selecting path into a single representation.
375324

‎scim2_models/path/resolution.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ def target_is_multivalued(self) -> bool | None:
108108
return None
109109
return model.get_field_multiplicity(self.target_field_name)
110110

111+
def get_annotation(self, annotation_type: type) -> Any:
112+
"""Return an annotation of the attribute the path designates.
113+
114+
The sub-attribute answers when the path names one, the head attribute
115+
otherwise, so ``emails.value`` reports the annotations of ``value``.
116+
117+
:param annotation_type: The annotation class, such as ``Mutability``.
118+
:returns: The annotation value, or :data:`None` when the attribute
119+
carries none, or when the head attribute holds no sub-attribute to
120+
answer for.
121+
"""
122+
model = self.target_model
123+
if model is None:
124+
return None
125+
return model.get_field_annotation(self.target_field_name, annotation_type)
126+
111127
def nested_in(self, urn: str) -> "AttributeBinding":
112128
"""Return the same attribute, qualified by the URN it was resolved under.
113129

‎tests/test_doc_examples.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def sorting_order(resources, attribute, sort_order=None):
261261
def sorting_key(resource, attribute):
262262
"""Return the single value a ``sortBy`` orders a resource by."""
263263
request = SearchRequest[User[EnterpriseUser]](sort_by=attribute)
264-
return sort_value(resource, request.sort_by)
264+
return sort_value(resource, request.sort_by.resolve())
265265

266266

267267
@pytest.mark.parametrize("attribute", ["emails", "emails.value"])

‎tests/test_dynamic_resources.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2888,7 +2888,7 @@ def test_a_model_built_at_runtime_is_collected_once_it_is_dropped():
28882888

28892889
# The assertions are made on values that do not name the model, since
28902890
# pytest keeps the operands of an assertion for as long as the test runs.
2891-
bound = Path[Model]("label").field_name == "label"
2891+
bound = Path[Model]("label").resolve().target_field_name == "label"
28922892
matched = ScimFilter[Model]('label eq "x"').match(Model(label="x"))
28932893
assert bound
28942894
assert matched

‎tests/test_filter_semantics.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -983,6 +983,7 @@ def test_the_model_of_a_sub_attribute_on_a_simple_attribute_is_unknown():
983983
)
984984
assert resolved.target_model is None
985985
assert resolved.target_is_multivalued is None
986+
assert resolved.get_annotation(Required) is None
986987

987988

988989
def test_an_extension_attribute_read_from_the_extension_itself():

0 commit comments

Comments
 (0)