Skip to content

Commit 20108d6

Browse files
committed
fix: designate a schema by the longest urn that matches
1 parent 7667b02 commit 20108d6

3 files changed

Lines changed: 97 additions & 40 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ Fixed
6666
answers ``invalidPath`` instead of raising an :exc:`AttributeError`.
6767
- A path is resolved against the attributes the model declares, not against the values the
6868
resource carries: ``name.unknown`` used to be refused only on a resource carrying a ``name``.
69+
- An extension may name itself under the URN of the resource it extends. Both URNs then prefix
70+
a path expressed in it, and the longest match designates the model.
71+
- A path qualified by a URN that is neither the schema of the model nor that of one of its
72+
extensions designates nothing. It used to resolve as though the URN were the right one.
6973

7074
[0.7.0] - 2026-09-05
7175
--------------------

‎scim2_models/path.py‎

Lines changed: 53 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import re
22
from collections import UserString
3+
from collections.abc import Iterable
34
from collections.abc import Iterator
45
from inspect import isclass
56
from typing import TYPE_CHECKING
@@ -41,6 +42,19 @@ def _is_in_schema(path_lower: str, schema: str) -> bool:
4142
return path_lower == schema_lower or path_lower.startswith(f"{schema_lower}:")
4243

4344

45+
def _designating_schema(path_lower: str, schemas: Iterable[str]) -> str | None:
46+
"""Return the schema a qualified path is expressed in, if any.
47+
48+
The longest match wins: the URN of an extension may extend the URN of the
49+
resource it extends, and then both match while only the longer one
50+
designates the model the attribute lives on.
51+
"""
52+
matching = [
53+
schema for schema in schemas if schema and _is_in_schema(path_lower, schema)
54+
]
55+
return max(matching, key=len) if matching else None
56+
57+
4458
def _to_comparable(value: Any) -> Any:
4559
"""Convert a value to a comparable form (dict for BaseModel)."""
4660
return value.model_dump() if isinstance(value, BaseModel) else value
@@ -334,23 +348,20 @@ def _resolve_model(self) -> tuple[type[BaseModel], str | None] | None:
334348
if ":" in self and isclass(model) and issubclass(model, Resource | Extension):
335349
path_lower = str(self).lower()
336350

337-
if model.__schema__ and path_lower == model.__schema__.lower():
351+
extension_models = (
352+
model.get_extension_models() if issubclass(model, Resource) else {}
353+
)
354+
schema = _designating_schema(
355+
path_lower, [model.__schema__ or "", *extension_models]
356+
)
357+
358+
if schema is None:
359+
return None
360+
361+
model = extension_models.get(schema, model)
362+
if path_lower == schema.lower():
338363
return model, None
339-
elif model.__schema__ and _is_in_schema(path_lower, model.__schema__):
340-
attr_path = str(self)[len(model.__schema__) :].lstrip(":")
341-
elif issubclass(model, Resource):
342-
for (
343-
extension_schema,
344-
extension_model,
345-
) in model.get_extension_models().items():
346-
schema_lower = extension_schema.lower()
347-
if path_lower == schema_lower:
348-
return extension_model, None
349-
elif _is_in_schema(path_lower, schema_lower):
350-
model = extension_model
351-
break
352-
else:
353-
return None
364+
attr_path = str(self)[len(schema) :].lstrip(":")
354365

355366
if not attr_path:
356367
return model, None
@@ -397,34 +408,36 @@ def _resolve_instance(
397408
if ":" not in path_str:
398409
return _Resolution(resource, path_str)
399410

400-
model_schema = getattr(type(resource), "__schema__", "") or ""
401411
path_lower = path_str.lower()
412+
model_schema = ""
413+
if isinstance(resource, Resource | Extension):
414+
model_schema = getattr(type(resource), "__schema__", "") or ""
415+
extension_models = (
416+
resource.get_extension_models() if isinstance(resource, Resource) else {}
417+
)
418+
419+
schema = _designating_schema(path_lower, [model_schema, *extension_models])
420+
if schema is None:
421+
if isinstance(resource, Resource):
422+
raise InvalidPathException(path=path_str)
423+
return None
402424

403-
if isinstance(resource, Resource | Extension) and _is_in_schema(
404-
path_lower, model_schema
405-
):
406-
is_explicit = path_lower == model_schema.lower()
407-
normalized = path_str[len(model_schema) :].lstrip(":")
408-
return _Resolution(resource, normalized, is_explicit)
409-
410-
if isinstance(resource, Resource):
411-
for ext_schema, ext_model in resource.get_extension_models().items():
412-
ext_schema_lower = ext_schema.lower()
413-
if path_lower == ext_schema_lower:
414-
return _Resolution(resource, ext_model.__name__)
415-
if _is_in_schema(path_lower, ext_schema_lower):
416-
sub_path = path_str[len(ext_schema) :].lstrip(":")
417-
ext_obj = getattr(resource, ext_model.__name__)
418-
if create and ext_obj is None:
419-
ext_obj = ext_model()
420-
setattr(resource, ext_model.__name__, ext_obj)
421-
if ext_obj is None:
422-
return None
423-
return _Resolution(ext_obj, sub_path)
425+
sub_path = path_str[len(schema) :].lstrip(":")
426+
ext_model = extension_models.get(schema)
424427

425-
raise InvalidPathException(path=str(self))
428+
if ext_model is None:
429+
return _Resolution(resource, sub_path, path_lower == schema.lower())
426430

427-
return None
431+
if path_lower == schema.lower():
432+
return _Resolution(resource, ext_model.__name__)
433+
434+
ext_obj = getattr(resource, ext_model.__name__)
435+
if create and ext_obj is None:
436+
ext_obj = ext_model()
437+
setattr(resource, ext_model.__name__, ext_obj)
438+
if ext_obj is None:
439+
return None
440+
return _Resolution(ext_obj, sub_path)
428441

429442
def _walk_to_target(
430443
self, obj: BaseModel, path_str: str, *, create: bool = False

‎tests/test_path.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@
55
import pydantic
66
import pytest
77

8+
from scim2_models import URN
89
from scim2_models import CaseExact
910
from scim2_models import Email
1011
from scim2_models import EnterpriseUser
12+
from scim2_models import Extension
1113
from scim2_models import Group
1214
from scim2_models import InvalidPathException
1315
from scim2_models import Manager
1416
from scim2_models import Mutability
1517
from scim2_models import Name
1618
from scim2_models import PathNotFoundException
1719
from scim2_models import Required
20+
from scim2_models import Resource
1821
from scim2_models import Returned
1922
from scim2_models import Uniqueness
2023
from scim2_models import User
@@ -347,6 +350,43 @@ def test_get_extension_attribute_uppercase_urn():
347350
assert path.get(user) == "12345"
348351

349352

353+
def test_a_foreign_schema_urn_designates_nothing():
354+
"""A qualified path is expressed in the schema of the model it applies to."""
355+
path = Path[EnterpriseUser]("urn:totally:unrelated:employeeNumber")
356+
assert path.model is None
357+
assert path.field_name is None
358+
assert path.urn is None
359+
360+
361+
def test_an_extension_urn_may_extend_the_urn_of_the_resource_it_extends():
362+
"""The longest matching schema designates the model, not the first one.
363+
364+
Nothing keeps an extension from naming itself under the namespace of the
365+
resource it extends, and both URNs then prefix the path.
366+
"""
367+
368+
class Ext(Extension):
369+
__schema__ = URN("urn:example:2.0:Thing:Ext")
370+
371+
label: str | None = None
372+
373+
class Thing(Resource):
374+
__schema__ = URN("urn:example:2.0:Thing")
375+
376+
title: str | None = None
377+
378+
thing = Thing[Ext](title="thing")
379+
thing[Ext] = Ext(label="labelled")
380+
381+
path = Path[Thing[Ext]]("urn:example:2.0:Thing:Ext:label")
382+
assert path.model is Ext
383+
assert path.field_name == "label"
384+
assert path.urn == "urn:example:2.0:Thing:Ext:label"
385+
assert path.get(thing) == "labelled"
386+
assert path.set(thing, "renamed") is True
387+
assert thing[Ext].label == "renamed"
388+
389+
350390
# --- Path.set() tests ---
351391

352392

0 commit comments

Comments
 (0)