@@ -57,6 +57,33 @@ def _scim_name(model: type[BaseModel], field_name: str) -> str:
5757
5858
5959class 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
0 commit comments