From 39f0341d7596e9ecee6cda79d490c6a688f8db8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:39:25 +0200 Subject: [PATCH 01/14] feat: filter engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScimFilter parses the filter grammar of RFC7644 §3.4.2.2, on a new lark dependency, and evaluates it against Python objects. Parsing, resolution against a model and evaluation are separate stages, so FilterVisitor can transpile a tree into a backend query rather than evaluate it. Binding a filter checks it as it is created, against one resource type or against a union of them: §3.4.2.1 has an attribute only some of them declare evaluate to false on the others, so only an attribute none declares is refused. The grammar and the resolution sit above the package, attrPath being one ABNF rule that RFC7644 uses in both FILTER and PATH (§3.5.2), and neither the classes a subscription answers nor the resolutions it caches outlive a resource type built at runtime. --- .pre-commit-config.yaml | 1 + doc/changelog.rst | 7 + pyproject.toml | 6 +- scim2_models/__init__.py | 4 + scim2_models/annotations.py | 5 +- scim2_models/expressions.py | 326 +++++++++ scim2_models/filters/__init__.py | 53 ++ scim2_models/filters/filter.py | 412 +++++++++++ scim2_models/filters/visitor.py | 345 +++++++++ scim2_models/grammar.py | 264 +++++++ scim2_models/resolution.py | 444 ++++++++++++ tests/conftest.py | 3 + tests/test_dynamic_resources.py | 5 +- tests/test_filter_grammar.py | 546 ++++++++++++++ tests/test_filter_semantics.py | 1153 ++++++++++++++++++++++++++++++ tests/test_template_strings.py | 65 ++ uv.lock | 15 +- 17 files changed, 3648 insertions(+), 6 deletions(-) create mode 100644 scim2_models/expressions.py create mode 100644 scim2_models/filters/__init__.py create mode 100644 scim2_models/filters/filter.py create mode 100644 scim2_models/filters/visitor.py create mode 100644 scim2_models/grammar.py create mode 100644 scim2_models/resolution.py create mode 100644 tests/test_filter_grammar.py create mode 100644 tests/test_filter_semantics.py create mode 100644 tests/test_template_strings.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2b223dcc..f58ca306 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,6 +27,7 @@ repos: exclude: ^(tests/|conftest\.py|doc/) additional_dependencies: - pydantic[email]>=2.7.0 + - lark>=1.3 - repo: https://github.com/codespell-project/codespell rev: v2.4.3 hooks: diff --git a/doc/changelog.rst b/doc/changelog.rst index 80f4eed4..aac8f003 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -6,6 +6,12 @@ Changelog Added ^^^^^ +- Support for the ``filter`` query parameter (:rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`) + with :class:`~scim2_models.ScimFilter`. See :doc:`filters`. :issue:`17` +- :meth:`ScimFilter.quote ` renders a value as a literal a filter + can carry, and on Python 3.14 :class:`~scim2_models.ScimFilter` takes a t-string: an + interpolated value is quoted so that it cannot be read as syntax, and an interpolated filter + is inserted as it stands. - :class:`~scim2_models.SearchRequest` and :class:`~scim2_models.ResponseParameters` take the resource type an endpoint serves, as in ``SearchRequest[User]``, which resolves :attr:`~scim2_models.SearchRequest.sort_by`, @@ -14,6 +20,7 @@ Added covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so does :class:`~scim2_models.Path`; a path resolves against the first type declaring it, so ``sortBy`` answers on a root query too. +- lark is a new dependency. Changed ^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index e57ddb1a..dabfecd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ classifiers = [ requires-python = ">= 3.10" dependencies = [ - "pydantic[email]>=2.12.0" + "lark>=1.3", + "pydantic[email]>=2.12.0", ] [project.urls] @@ -79,6 +80,9 @@ exclude_lines = [ "if TYPE_CHECKING:", ] +[tool.ruff] +per-file-target-version = { "tests/test_template_strings.py" = "py314" } + [tool.ruff.lint] select = [ "B", # flake8-bugbear diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 3badf89d..4189308a 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -32,6 +32,7 @@ from .exceptions import SensitiveException from .exceptions import TooManyException from .exceptions import UniquenessException +from .filters import ScimFilter from .lookup import get_model_by_payload from .lookup import get_model_by_schema from .messages.bulk import BulkOperation @@ -48,6 +49,7 @@ from .reference import URI from .reference import External from .reference import Reference +from .resolution import ResolvedAttribute from .resources.enterprise_user import EnterpriseUser from .resources.enterprise_user import Manager from .resources.group import Group @@ -144,6 +146,7 @@ "ReplacementRequestContext", "ReplacementResponseContext", "Required", + "ResolvedAttribute", "Resource", "ResourceType", "ResponseParameters", @@ -152,6 +155,7 @@ "SCIMException", "SCIMSerializer", "SCIMValidator", + "ScimFilter", "Schema", "SchemaExtension", "ScimObject", diff --git a/scim2_models/annotations.py b/scim2_models/annotations.py index 46bbfaf3..b14e44c4 100644 --- a/scim2_models/annotations.py +++ b/scim2_models/annotations.py @@ -163,9 +163,8 @@ class CaseExact(Enum): ``binary`` and ``reference`` attributes are case-exact whatever their schema says, per :rfc:`RFC7643 §2.3.6 <7643#section-2.3.6>` and - :rfc:`§2.3.7 <7643#section-2.3.7>`. Comparing is up to the service provider; - what this library does with the annotation is publish it in - :meth:`~scim2_models.Resource.to_schema`. + :rfc:`§2.3.7 <7643#section-2.3.7>`. Filter comparisons honour it, and + :meth:`~scim2_models.Resource.to_schema` publishes it. """ false = False diff --git a/scim2_models/expressions.py b/scim2_models/expressions.py new file mode 100644 index 00000000..d1fbbd32 --- /dev/null +++ b/scim2_models/expressions.py @@ -0,0 +1,326 @@ +import json +import re +import sys +from collections.abc import Callable +from dataclasses import dataclass +from dataclasses import field +from datetime import datetime +from enum import Enum +from typing import Any +from typing import Union + +if sys.version_info >= (3, 14): + from string.templatelib import Template as Template +else: # pragma: no cover + Template = None + +_ATTR_NAME = r"\$?[A-Za-z][A-Za-z0-9_-]*" +"""The ``ATTRNAME`` rule, with the leading ``$`` of ``$ref`` that errata 8924 adds.""" + +_URN = r"urn:[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*" +"""A schema URN, the only ``URI`` an attribute path accepts.""" + +_ATTR_NAME_RE = re.compile(_ATTR_NAME) +_URN_RE = re.compile(_URN, re.IGNORECASE) + + +class _Expression(str): + """A filter or a path, kept as the string it was built from. + + Its syntax was checked when it was created, so it can only contribute + syntax. Interpolated in a t-string, it is inserted as it stands where any + other value is quoted. + """ + + +class CompareOperator(str, Enum): + """The comparison operators defined at :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`.""" + + eq = "eq" + """Equal.""" + + ne = "ne" + """Not equal.""" + + co = "co" + """Contains.""" + + sw = "sw" + """Starts with.""" + + ew = "ew" + """Ends with.""" + + gt = "gt" + """Greater than.""" + + ge = "ge" + """Greater than or equal to.""" + + lt = "lt" + """Less than.""" + + le = "le" + """Less than or equal to.""" + + +ORDERING_OPERATORS = frozenset( + { + CompareOperator.gt, + CompareOperator.ge, + CompareOperator.lt, + CompareOperator.le, + } +) +"""Operators that impose an ordering, and are thus invalid on boolean and +binary attributes per :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`.""" + +STRING_OPERATORS = frozenset( + {CompareOperator.co, CompareOperator.sw, CompareOperator.ew} +) +"""Operators that require a string operand.""" + + +class LogicalOperator(str, Enum): + """The logical operators defined at :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`.""" + + and_ = "and" + """Conjunction.""" + + or_ = "or" + """Disjunction.""" + + +def _json_default(value: Any) -> str: + """Render a dateTime, the one value a filter compares that JSON has no syntax for.""" + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"{type(value).__name__} is not a value a filter can carry") + + +def _quote(value: Any) -> str: + """Render a comparison value using the JSON syntax mandated by the ABNF. + + A :class:`~datetime.datetime` renders as the string + :rfc:`RFC7643 §2.3.5 <7643#section-2.3.5>` gives a dateTime. + + :raises ValueError: If the value is a float that JSON cannot express, such + as an infinity, which would render as a literal no parser accepts. + :raises TypeError: If the value is of a type no filter can carry. + """ + return json.dumps(value, allow_nan=False, default=_json_default) + + +_CONVERSIONS: dict[str, Callable[[Any], str]] = {"a": ascii, "r": repr, "s": str} + + +def _render_template(template: "Template") -> str: + """Render a t-string into filter syntax, quoting every interpolated value. + + A conversion or a format specification applies first, as in an f-string, + and what it yields is a string. A value interpolated as it stands keeps its + type, so ``{True}`` renders as ``true`` and ``{18}`` as ``18``. + + An :class:`_Expression`, a filter or a path, is syntax rather than a value + and is inserted unquoted. A conversion or a format specification turns it + into a value like any other. + """ + parts: list[str] = [] + for part in template: + if isinstance(part, str): + parts.append(part) + continue + value = part.value + if part.conversion is not None: + value = _CONVERSIONS[part.conversion](value) + if part.format_spec: + value = format(value, part.format_spec) + if isinstance(value, _Expression): + parts.append(str(value)) + continue + parts.append(_quote(value)) + return "".join(parts) + + +def _text(expression: Any) -> str: + """Return the text of an expression, a t-string rendered with its values quoted.""" + if Template is not None and isinstance(expression, Template): + return _render_template(expression) + return str(expression) + + +@dataclass(frozen=True, slots=True) +class AttrPath: + """An attribute path as defined by the ``attrPath`` ABNF rule. + + ``attrPath = [URI ":"] ATTRNAME *1subAttr`` + + The names and the URN are checked as they are given, so a node built by + hand carries nothing the grammar would not read. + + :raises ValueError: If a name is not an ``ATTRNAME``, or the URI not a URN. + """ + + attr: str + """The attribute name, e.g. ``emails``.""" + + sub_attr: str | None = None + """The sub-attribute name, e.g. ``type`` in ``emails.type``.""" + + uri: str | None = None + """The schema URN the attribute belongs to, when explicitly qualified.""" + + def __post_init__(self) -> None: + for name in (self.attr, self.sub_attr): + if name is not None and not _ATTR_NAME_RE.fullmatch(name): + raise ValueError(f"{name!r} is not an attribute name") + if self.uri is not None and not _URN_RE.fullmatch(self.uri): + raise ValueError(f"{self.uri!r} is not a schema URN") + + def __str__(self) -> str: + prefix = f"{self.uri}:" if self.uri else "" + suffix = f".{self.sub_attr}" if self.sub_attr else "" + return f"{prefix}{self.attr}{suffix}" + + +@dataclass(frozen=True, slots=True) +class FilterNode: + """Base class for every filter expression node. + + Nodes compose with the Python boolean operators, which is how a filter is + built without going through a string:: + + >>> from scim2_models.filters import AttrPath, Comparison, CompareOperator, Present + + >>> work = Comparison(AttrPath("type"), CompareOperator.eq, "work") + >>> primary = Present(AttrPath("primary")) + >>> str(work & primary) + 'type eq "work" and primary pr' + >>> str(~work) + 'not (type eq "work")' + """ + + def __str__(self) -> str: + raise NotImplementedError + + def _combine(self, other: "FilterNode", op: "LogicalOperator") -> "FilterNode": + """Join two expressions, flattening a chain of the same operator.""" + terms: tuple[FilterNode, ...] = () + for node in (self, other): + if isinstance(node, LogicalExpr) and node.op == op: + terms += node.terms + else: + terms += (node,) + return LogicalExpr(op=op, terms=terms) + + def __and__(self, other: "FilterNode") -> "FilterNode": + return self._combine(other, LogicalOperator.and_) + + def __or__(self, other: "FilterNode") -> "FilterNode": + return self._combine(other, LogicalOperator.or_) + + def __invert__(self) -> "FilterNode": + return Not(expr=self) + + +@dataclass(frozen=True, slots=True) +class Comparison(FilterNode): + """A comparison between an attribute and a value, e.g. ``userName eq "bjensen"``.""" + + attr_path: AttrPath + """The compared attribute.""" + + op: CompareOperator + """The comparison operator.""" + + value: Any + """The raw comparison value, as read from the filter string.""" + + def __str__(self) -> str: + return f"{self.attr_path} {self.op.value} {_quote(self.value)}" + + +@dataclass(frozen=True, slots=True) +class Present(FilterNode): + """A presence test, e.g. ``title pr``.""" + + attr_path: AttrPath + """The tested attribute.""" + + def __str__(self) -> str: + return f"{self.attr_path} pr" + + +@dataclass(frozen=True, slots=True) +class Not(FilterNode): + """A negated expression, e.g. ``not (title pr)``.""" + + expr: FilterNode + """The negated expression.""" + + def __str__(self) -> str: + return f"not ({self.expr})" + + +@dataclass(frozen=True, slots=True) +class LogicalExpr(FilterNode): + """A conjunction or disjunction of two or more expressions. + + Chained operators are flattened, so ``a eq 1 and b eq 2 and c eq 3`` + yields a single node holding three terms. + """ + + op: LogicalOperator + """The logical operator joining the terms.""" + + terms: tuple[FilterNode, ...] = field(default_factory=tuple) + """The joined expressions, in source order.""" + + def __str__(self) -> str: + separator = f" {self.op.value} " + return separator.join( + f"({term})" if _needs_parentheses(self, term) else str(term) + for term in self.terms + ) + + +@dataclass(frozen=True, slots=True) +class ValuePath(FilterNode): + """A value selection on a multi-valued attribute, e.g. ``emails[type eq "work"]``. + + ``sub_attr`` is only ever set when the node comes from a PATCH path, since + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows ``valuePath [subAttr]`` + where a filter does not. + """ + + attr_path: AttrPath + """The multi-valued attribute being selected into.""" + + val_filter: FilterNode + """The filter selecting the matching values.""" + + sub_attr: str | None = None + """The sub-attribute targeted past the selection, in a PATCH path.""" + + def __str__(self) -> str: + suffix = f".{self.sub_attr}" if self.sub_attr else "" + return f"{self.attr_path}[{self.val_filter}]{suffix}" + + +def _needs_parentheses(parent: LogicalExpr, child: FilterNode) -> bool: + """Whether a child expression must be parenthesised inside its parent. + + Only a disjunction nested in a conjunction needs them, since ``and`` binds + tighter than ``or`` in the operator precedence of :rfc:`RFC7644 §3.4.2.2 + <7644#section-3.4.2.2>`. + """ + return ( + isinstance(child, LogicalExpr) + and child.op == LogicalOperator.or_ + and parent.op == LogicalOperator.and_ + ) + + +PathNode = Union[AttrPath, ValuePath, Comparison, Present] # noqa: UP007 +"""A parsed PATCH path, per the ``PATH`` rule of :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` +as corrected by errata 7122: ``PATH = attrPath / valuePath [subAttr] / attrExp``.""" diff --git a/scim2_models/filters/__init__.py b/scim2_models/filters/__init__.py new file mode 100644 index 00000000..c942f568 --- /dev/null +++ b/scim2_models/filters/__init__.py @@ -0,0 +1,53 @@ +from ..expressions import ORDERING_OPERATORS +from ..expressions import STRING_OPERATORS +from ..expressions import AttrPath +from ..expressions import CompareOperator +from ..expressions import Comparison +from ..expressions import FilterNode +from ..expressions import LogicalExpr +from ..expressions import LogicalOperator +from ..expressions import Not +from ..expressions import PathNode +from ..expressions import Present +from ..expressions import ValuePath +from ..grammar import parse_filter +from ..grammar import parse_path +from ..resolution import attribute_host +from ..resolution import coerce_value +from ..resolution import resolve_attr_path +from ..resolution import resolve_comparison_path +from ..resolution import validate_operator +from ..resolution import validate_value_selection +from .filter import ScimFilter +from .visitor import Evaluator +from .visitor import FilterVisitor +from .visitor import compare +from .visitor import is_present + +__all__ = [ + "ORDERING_OPERATORS", + "STRING_OPERATORS", + "AttrPath", + "CompareOperator", + "Comparison", + "Evaluator", + "FilterNode", + "FilterVisitor", + "LogicalExpr", + "LogicalOperator", + "Not", + "PathNode", + "Present", + "ScimFilter", + "ValuePath", + "attribute_host", + "coerce_value", + "compare", + "is_present", + "parse_filter", + "parse_path", + "resolve_attr_path", + "resolve_comparison_path", + "validate_operator", + "validate_value_selection", +] diff --git a/scim2_models/filters/filter.py b/scim2_models/filters/filter.py new file mode 100644 index 00000000..bdddd1c1 --- /dev/null +++ b/scim2_models/filters/filter.py @@ -0,0 +1,412 @@ +from collections.abc import MutableMapping +from inspect import isclass +from typing import TYPE_CHECKING +from typing import Any +from typing import Generic +from typing import TypeVar +from weakref import WeakValueDictionary + +from pydantic import GetCoreSchemaHandler +from pydantic import GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + +from ..base import BaseModel +from ..expressions import AttrPath +from ..expressions import Comparison +from ..expressions import FilterNode +from ..expressions import LogicalExpr +from ..expressions import Not +from ..expressions import Present +from ..expressions import Template +from ..expressions import ValuePath +from ..expressions import _Expression +from ..expressions import _quote +from ..expressions import _text +from ..grammar import parse_filter +from ..resolution import ResolvedAttribute +from ..resolution import coerce_value +from ..resolution import resolve_filter_path +from ..resolution import validate_operator +from ..resolution import validate_value_selection +from ..utils import _model_union +from .visitor import Evaluator +from .visitor import FilterVisitor + +if TYPE_CHECKING: + from ..resources.resource import Resource + +ResourceT = TypeVar("ResourceT", bound="Resource[Any]") + +_FILTER_CACHE: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( + WeakValueDictionary() +) +"""The classes subscription has already built, so that two subscriptions of the +same resource types answer the same class. + +The classes are held weakly, as a path binding holds its own: one bound to a +model built at runtime would otherwise keep that model alive for as long as the +process runs.""" + + +class _Validator(FilterVisitor[None]): + """Check every expression of a filter against the model it applies to.""" + + def __init__(self, *models: type[BaseModel], strict: bool, urn_prefix: str = ""): + self.models = models + self.strict = strict + self.urn_prefix = urn_prefix + + def _resolve( + self, attr_path: AttrPath, *, for_comparison: bool = False + ) -> ResolvedAttribute | None: + """Bind an attribute path to the first model of the union declaring it. + + An endpoint covering several resource types accepts a filter naming an + attribute only some of them declare, since + :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has the others evaluate + it to false. Resolution therefore fails only when no model declares it. + """ + for model in self.models: + resolved = resolve_filter_path( + model, attr_path, strict=False, for_comparison=for_comparison + ) + if resolved is not None: + if not self.urn_prefix: + return resolved + return resolved.nested_in(self.urn_prefix) + + if self.strict: + # Let the first model raise, so the error names the attribute. + resolve_filter_path( + self.models[0], + attr_path, + strict=True, + for_comparison=for_comparison, + ) + return None + + def visit_not(self, node: Not) -> None: + self.visit(node.expr) + + def visit_logical_expr(self, node: LogicalExpr) -> None: + for term in node.terms: + self.visit(term) + + def visit_present(self, node: Present) -> None: + self._resolve(node.attr_path) + + def visit_comparison(self, node: Comparison) -> None: + resolved = self._resolve(node.attr_path, for_comparison=True) + if resolved is None: + return + validate_operator(resolved, node.op) + coerce_value(resolved, node.value, node.op) + + def visit_value_path(self, node: ValuePath) -> None: + resolved = self._resolve(node.attr_path) + if resolved is None: + return + + validate_value_selection(resolved) + + # A list of scalars has no model to resolve the inner filter against. + item_model = resolved.field_type + if isclass(item_model) and issubclass(item_model, BaseModel): + _Validator(item_model, strict=self.strict, urn_prefix=resolved.urn).visit( + node.val_filter + ) + + +class ScimFilter(_Expression, Generic[ResourceT]): + """A SCIM filter, as defined at :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`. + + A filter *is* the string it was built from, so it can be passed around, + compared and sliced like one, while also exposing its parsed form through + :attr:`ast` and its semantics through :meth:`match`. + + Syntax is checked on creation. Binding to a model, which is what resolves + attribute names and value types, requires a parameterised type:: + + >>> from scim2_models import ScimFilter, User + + >>> ScimFilter('userName eq "bjensen"') == 'userName eq "bjensen"' + True + + >>> user = User(user_name="bjensen") + >>> ScimFilter[User]('userName eq "BJensen"').match(user) + True + + A bound filter is checked against its models as it is created, so an + operator or a value its attribute cannot take is refused straight away. + Unknown attributes are the exception, since + :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` requires an endpoint covering + several resource types to evaluate them to false; binding the filter to all + of them, as :class:`~scim2_models.SearchRequest` does, is what turns an + attribute none of them declares into an error. + + Attribute names are matched case-insensitively, and so is the comparison + itself unless the attribute is annotated + :attr:`CaseExact.true `. + + On Python 3.14, a filter can be written as a t-string. Each interpolated + value is quoted, so it stays a value whatever it contains. Another filter + is inserted as it stands, since its syntax was checked when it was + created:: + + ScimFilter[User](t"userName eq {name}") + + :meth:`quote` quotes a value dropped into a plain string the same way. + """ + + __scim_models__: tuple[type[BaseModel], ...] = () + + _ast: FilterNode + + def __class_getitem__(cls, model: type[ResourceT]) -> type["ScimFilter[ResourceT]"]: + """Create a filter class bound to a resource type, or to a union of them. + + A union is what an endpoint covering several resource types binds, such + as the server root of :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`. + """ + models = _model_union(model) + if models is None: + return super().__class_getitem__(model) # type: ignore[misc,no-any-return] + + cache_key = (cls, models) + if cache_key not in _FILTER_CACHE: + name = ", ".join(each.__name__ for each in models) + _FILTER_CACHE[cache_key] = type( + f"ScimFilter[{name}]", (cls,), {"__scim_models__": models} + ) + return _FILTER_CACHE[cache_key] + + def __new__( + cls, expression: "str | ScimFilter[Any] | FilterNode | Template" + ) -> "ScimFilter[ResourceT]": + filter_ = super().__new__(cls, _text(expression)) + # Parsing on creation is what makes an invalid filter fail early, and + # the tree is kept so that it is only ever parsed once. + filter_._ast = ( + expression + if isinstance(expression, FilterNode) + else parse_filter(str(filter_)) + ) + # Semantics are checked here rather than on evaluation, where the + # boolean operators short-circuit and would only report the branches + # they walk. Unknown attributes are left out, as an endpoint covering + # several resource types binds the same filter to each of its types and + # must evaluate them to false. + if cls.__scim_models__: + filter_._validate_semantics(strict=False) + return filter_ + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: type[Any], + _handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + def validate(value: Any) -> "ScimFilter[Any]": + if isinstance(value, str): + return cls(str(value)) + raise ValueError(f"Expected str or ScimFilter, got {type(value).__name__}") + + return core_schema.no_info_plain_validator_function( + validate, + serialization=core_schema.plain_serializer_function_ser_schema(str), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, + _core_schema: core_schema.CoreSchema, + _handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + return {"type": "string"} + + @property + def ast(self) -> FilterNode: + """The parsed form of the filter. + + This is the tree a transpiler walks with a + :class:`~scim2_models.filters.FilterVisitor`. + """ + return self._ast + + @staticmethod + def quote(value: Any) -> str: + r"""Render a value as a literal a filter can carry, quotes included. + + A string is quoted and escaped, so that it stays a value whatever it + contains. A number, a boolean, :data:`None` or a + :class:`~datetime.datetime` render as the syntax their attribute type + expects. The literal fills an unquoted slot of the expression:: + + >>> from scim2_models import ScimFilter + >>> untrusted = 'x" or userName pr' + >>> print(ScimFilter.quote(untrusted)) + "x\" or userName pr" + >>> print(ScimFilter(f"userName eq {ScimFilter.quote(untrusted)}")) + userName eq "x\" or userName pr" + >>> ScimFilter.quote(True), ScimFilter.quote(18), ScimFilter.quote(None) + ('true', '18', 'null') + >>> from datetime import datetime, timezone + >>> ScimFilter.quote(datetime(2011, 5, 13, 4, 42, 34, tzinfo=timezone.utc)) + '"2011-05-13T04:42:34+00:00"' + + :raises TypeError: If the value is of a type no filter can carry. + """ + return _quote(value) + + def _single_model(self, method: str) -> "type[BaseModel] | None": + """Return the one model this filter resolves against, if there is one. + + :raises TypeError: If the filter is bound to a union, which has no + single model to resolve against. A transpiler emits one query per + resource type, so it binds the filter to each of them in turn. + """ + if len(self.__scim_models__) > 1: + names = ", ".join(each.__name__ for each in self.__scim_models__) + raise TypeError( + f"{method} requires a filter bound to a single model, " + f"got a union of {names}" + ) + return self.model + + @property + def models(self) -> tuple[type[BaseModel], ...]: + """The resource types this filter is bound to, empty when it is unbound. + + It holds several models when the filter is bound to a union, which is + what an endpoint covering several resource types does. + """ + return self.__scim_models__ + + @property + def model(self) -> type[BaseModel] | None: + """The model this filter is bound to, or :data:`None` when it is unbound. + + A filter bound to a union has no single model; read :attr:`models` + instead. + """ + return self.__scim_models__[0] if len(self.__scim_models__) == 1 else None + + def resolve( + self, attr_path: AttrPath, *, strict: bool = True + ) -> ResolvedAttribute | None: + """Bind an attribute path of this filter to the model it designates. + + The path is resolved as it is written, which is what a presence test + or a value selection needs. A comparison additionally follows the + ``value`` convention, for which :meth:`resolve_comparison` is the + right method. + + :param attr_path: The attribute path to resolve, usually taken from a + node of :attr:`ast`. + :param strict: Whether an unknown attribute raises instead of + returning :data:`None`. + :returns: The resolved attribute, or :data:`None` when the filter is + not bound to a model. + :raises InvalidFilterException: If ``strict`` and the attribute is unknown. + + >>> from scim2_models import ScimFilter, User + >>> from scim2_models.filters import AttrPath + + >>> resolved = ScimFilter[User]('emails.type eq "work"').resolve( + ... AttrPath(attr="emails", sub_attr="type") + ... ) + >>> resolved.field_name, resolved.sub_field_name, resolved.is_multivalued + ('emails', 'type', True) + """ + if (model := self._single_model("resolve")) is None: + return None + return resolve_filter_path(model, attr_path, strict=strict) + + def resolve_comparison( + self, attr_path: AttrPath, *, strict: bool = True + ) -> ResolvedAttribute | None: + """Bind the attribute path of a comparison to the model it designates. + + A comparison against a multi-valued complex attribute applies to its + ``value`` sub-attribute, so ``members co "x"`` compares + ``members.value``. This is what :meth:`match` compares, and what a + transpiler must emit its query against, down to the case sensitivity, + which the sub-attribute carries rather than the attribute holding it. + + :param attr_path: The attribute path to resolve, usually taken from a + :class:`~scim2_models.filters.Comparison` node of :attr:`ast`. + :param strict: Whether an unknown attribute raises instead of + returning :data:`None`. + :returns: The resolved attribute, or :data:`None` when the filter is + not bound to a model. + :raises InvalidFilterException: If ``strict`` and the attribute is unknown. + + >>> from scim2_models import Group, ScimFilter + >>> from scim2_models.filters import AttrPath + + >>> scim_filter = ScimFilter[Group]('members co "2819c223"') + >>> resolved = scim_filter.resolve_comparison(AttrPath(attr="members")) + >>> resolved.field_name, resolved.sub_field_name, resolved.case_exact + ('members', 'value', True) + """ + if (model := self._single_model("resolve_comparison")) is None: + return None + return resolve_filter_path(model, attr_path, strict=strict, for_comparison=True) + + def _validate_semantics(self, *, strict: bool = True) -> None: + """Check the whole filter against the models it is bound to. + + Creating a bound filter runs this with ``strict=False``, which rejects + an operator an attribute type forbids, a value that cannot be read as + that type, and a value selection over an attribute holding a single + value. Unknown attributes are left to ``strict``, which + :class:`~scim2_models.SearchRequest` turns on: naming the resource + types an endpoint serves is what makes an attribute none of them + declares a client error, where + :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has an unparameterised + request evaluate it to false instead. + + :param strict: Whether unknown attributes raise instead of being ignored. + :raises InvalidFilterException: If ``strict`` and an attribute is unknown, + or if a value or an operator is not valid for its attribute. + """ + if not self.__scim_models__: + return + _Validator(*self.__scim_models__, strict=strict).visit(self.ast) + + def match(self, resource: ResourceT, *, strict: bool = False) -> bool: + """Whether a resource satisfies this filter. + + Attribute names are compared case-insensitively, and so are string + values unless the attribute is annotated + :attr:`CaseExact.true `. A filter over a + multi-valued attribute matches when any of its values matches. + + :param resource: The resource to test. + :param strict: Whether an unknown attribute raises instead of not matching. + :returns: :data:`True` if the resource matches. + :raises TypeError: If the filter is not bound to a model. + :raises InvalidFilterException: If ``strict`` and the filter names an + attribute the model does not declare. Anything else a filter can get + wrong about its model is already refused when the bound filter is + created. + + >>> from scim2_models import ScimFilter, User + + >>> user = User( + ... user_name="bjensen", + ... emails=[{"type": "work", "value": "bjensen@example.com"}], + ... ) + >>> ScimFilter[User]('emails[type eq "work"]').match(user) + True + >>> ScimFilter[User]('emails[type eq "home"]').match(user) + False + >>> ScimFilter[User]('userName sw "bj" and title pr').match(user) + False + """ + if not self.__scim_models__: + raise TypeError("match requires a bound filter type: ScimFilter[Model]") + + model = self.model or type(resource) + return Evaluator(model, resource, strict=strict).visit(self.ast) diff --git a/scim2_models/filters/visitor.py b/scim2_models/filters/visitor.py new file mode 100644 index 00000000..48b15261 --- /dev/null +++ b/scim2_models/filters/visitor.py @@ -0,0 +1,345 @@ +from typing import Any +from typing import Generic +from typing import TypeVar +from unicodedata import normalize + +from ..base import BaseModel +from ..expressions import AttrPath +from ..expressions import CompareOperator +from ..expressions import Comparison +from ..expressions import FilterNode +from ..expressions import LogicalExpr +from ..expressions import LogicalOperator +from ..expressions import Not +from ..expressions import Present +from ..expressions import ValuePath +from ..resolution import ResolvedAttribute +from ..resolution import attribute_host +from ..resolution import coerce_value +from ..resolution import resolve_filter_path +from ..resolution import validate_operator +from ..resolution import validate_value_selection + +T = TypeVar("T") + + +class FilterVisitor(Generic[T]): + """Dispatch over the nodes of a filter tree. + + Subclasses implement one method per node type. Write a transpiler by + returning whatever your backend understands:: + + class SqlVisitor(FilterVisitor[str]): + def visit_comparison(self, node): + return f"{node.attr_path.attr} = ?" + + def visit_logical_expr(self, node): + joiner = " AND " if node.op == LogicalOperator.and_ else " OR " + return "(" + joiner.join(self.visit(t) for t in node.terms) + ")" + """ + + def visit(self, node: FilterNode) -> T: + """Dispatch a node to the matching ``visit_*`` method. + + :param node: The node to visit. + :raises TypeError: If the node type is unknown. + """ + if isinstance(node, Comparison): + return self.visit_comparison(node) + if isinstance(node, Present): + return self.visit_present(node) + if isinstance(node, Not): + return self.visit_not(node) + if isinstance(node, LogicalExpr): + return self.visit_logical_expr(node) + if isinstance(node, ValuePath): + return self.visit_value_path(node) + raise TypeError(f"Unsupported filter node: {type(node).__name__}") + + def visit_comparison(self, node: Comparison) -> T: + """Visit an attribute comparison.""" + raise NotImplementedError + + def visit_present(self, node: Present) -> T: + """Visit a presence test.""" + raise NotImplementedError + + def visit_not(self, node: Not) -> T: + """Visit a negation.""" + raise NotImplementedError + + def visit_logical_expr(self, node: LogicalExpr) -> T: + """Visit a conjunction or a disjunction.""" + raise NotImplementedError + + def visit_value_path(self, node: ValuePath) -> T: + """Visit a value selection on a multi-valued attribute.""" + raise NotImplementedError + + +def is_present(value: Any) -> bool: + """Whether a value satisfies the ``pr`` operator. + + :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` defines a match as a + "non-empty or non-null value, or ... a non-empty node for complex + attributes". ``False`` and ``0`` are values, and are thus present, while a + complex attribute whose sub-attributes are all unassigned is not. + """ + if value is None: + return False + if isinstance(value, str | bytes): + return len(value) > 0 + if isinstance(value, BaseModel): + return any( + is_present(getattr(value, name, None)) for name in type(value).model_fields + ) + if isinstance(value, dict): + return any(is_present(item) for item in value.values()) + if isinstance(value, list | tuple | set): + return any(is_present(item) for item in value) + return True + + +def _comparable(value: Any, case_exact: bool) -> Any: + """Reduce a value to the form comparisons are performed on.""" + if not isinstance(value, str): + return value + + normalized = normalize("NFC", value) + if case_exact: + return normalized + + # Case folding does not preserve the normalization form, so NFC is applied + # to its result too, and every operand comes out in the same form. + return normalize("NFC", normalized.casefold()) + + +def compare( + actual: Any, expected: Any, op: CompareOperator, *, case_exact: bool = False +) -> bool: + """Apply a comparison operator to a single pair of values. + + Values of incomparable types never match, rather than raising, so that a + filter over a heterogeneous collection stays usable. + + :param actual: The value read from the resource. + :param expected: The value the filter compares against. + :param op: The comparison operator. + :param case_exact: Whether string comparison is case-sensitive. + """ + if actual is None or expected is None: + if op == CompareOperator.eq: + return actual is None and expected is None + if op == CompareOperator.ne: + return (actual is None) != (expected is None) + return False + + left = _comparable(actual, case_exact) + right = _comparable(expected, case_exact) + + if op in (CompareOperator.co, CompareOperator.sw, CompareOperator.ew): + if not isinstance(left, str) or not isinstance(right, str): + return False + if op == CompareOperator.co: + return right in left + if op == CompareOperator.sw: + return left.startswith(right) + return left.endswith(right) + + try: + if op == CompareOperator.eq: + return bool(left == right) + if op == CompareOperator.ne: + return bool(left != right) + if op == CompareOperator.gt: + return bool(left > right) + if op == CompareOperator.ge: + return bool(left >= right) + if op == CompareOperator.lt: + return bool(left < right) + return bool(left <= right) + except TypeError: + return False + + +class Evaluator(FilterVisitor[bool]): + """Evaluate a filter against a Python object. + + This is the reference implementation of :class:`FilterVisitor`, used by + :meth:`ScimFilter.match `. It doubles as the proof that the + visitor API is enough to build a complete backend. + + :param model: The model the filter attributes are resolved against. + :param obj: The object being tested. + :param strict: Whether unknown attributes raise instead of not matching. + :param urn_prefix: The URN of the enclosing attribute, when evaluating the + inner filter of a value selection, so that errors name the whole path. + """ + + def __init__( + self, + model: type[BaseModel], + obj: Any, + *, + strict: bool = False, + urn_prefix: str = "", + ): + self.model = model + self.obj = obj + self.strict = strict + self.urn_prefix = urn_prefix + + def _resolve( + self, attr_path: AttrPath, *, for_comparison: bool = False + ) -> ResolvedAttribute | None: + resolved = resolve_filter_path( + self.model, attr_path, strict=self.strict, for_comparison=for_comparison + ) + if resolved is None or not self.urn_prefix: + return resolved + return resolved.nested_in(self.urn_prefix) + + def _read(self, resolved: ResolvedAttribute) -> Any: + """Read the compared value off the object, flattening multi-valued attributes.""" + host = attribute_host(self.obj, resolved) + if host is None: + return None + + head = getattr(host, resolved.field_name, None) + + if resolved.sub_field_name is None: + return head + + if isinstance(head, list): + return [ + getattr(item, resolved.sub_field_name, None) + for item in head + if item is not None + ] + + if head is None: + return None + + return getattr(head, resolved.sub_field_name, None) + + def visit_comparison(self, node: Comparison) -> bool: + resolved = self._resolve(node.attr_path, for_comparison=True) + if resolved is None: + return False + + validate_operator(resolved, node.op) + expected = coerce_value(resolved, node.value, node.op) + actual = self._read(resolved) + + if not isinstance(actual, list): + return compare(actual, expected, node.op, case_exact=resolved.case_exact) + + # A filter on a multi-valued attribute matches if any of its values + # matches. The RFC does not say what that means for "ne", so the + # universal reading is used: no value equals the operand. + if node.op == CompareOperator.ne: + return all( + compare(item, expected, node.op, case_exact=resolved.case_exact) + for item in actual + ) + + return any( + compare(item, expected, node.op, case_exact=resolved.case_exact) + for item in actual + ) + + def visit_present(self, node: Present) -> bool: + resolved = self._resolve(node.attr_path) + if resolved is None: + return False + + actual = self._read(resolved) + if isinstance(actual, list) and resolved.sub_field_name is not None: + return any(is_present(item) for item in actual) + return is_present(actual) + + def visit_not(self, node: Not) -> bool: + return not self.visit(node.expr) + + def visit_logical_expr(self, node: LogicalExpr) -> bool: + results = (self.visit(term) for term in node.terms) + return all(results) if node.op == LogicalOperator.and_ else any(results) + + def visit_value_path(self, node: ValuePath) -> bool: + """Match when at least one value of the attribute satisfies the inner filter.""" + return len(self.select(node)) > 0 + + def select(self, node: ValuePath) -> list[Any]: + """Return the values of a multi-valued attribute matching a value selection. + + This is what a PATCH operation needs in order to know which entries of + a list it has to modify. + + :param node: The value selection to apply. + :returns: The matching values, in their original order. + """ + resolved = self._resolve(node.attr_path) + if resolved is None: + return [] + + validate_value_selection(resolved) + + host = attribute_host(self.obj, resolved) + values = getattr(host, resolved.field_name, None) if host is not None else None + + if not isinstance(values, list): + return [] + + return [ + item + for item in values + if self._matches_item(item, node.val_filter, resolved) + ] + + def _matches_item( + self, item: Any, val_filter: FilterNode, resolved: ResolvedAttribute + ) -> bool: + """Evaluate a value filter against one entry of a multi-valued attribute.""" + if isinstance(item, BaseModel): + return Evaluator( + type(item), item, strict=self.strict, urn_prefix=resolved.urn + ).visit(val_filter) + + return _ScalarEvaluator(item, case_exact=resolved.case_exact).visit(val_filter) + + +class _ScalarEvaluator(FilterVisitor[bool]): + """Evaluate a value filter against a scalar entry of a multi-valued attribute. + + A multi-valued attribute that is not complex, such as ``schemas``, holds + plain values with no sub-attribute to compare. Implementations + conventionally address those with ``value``, as in + ``schemas[value eq "urn:…"]``, so ``value`` is understood here as the entry + itself. Any other attribute name cannot match. + """ + + def __init__(self, value: Any, *, case_exact: bool = False): + self.value = value + self.case_exact = case_exact + + def _targets_self(self, node: Comparison | Present) -> bool: + return node.attr_path.attr.lower() == "value" and not node.attr_path.sub_attr + + def visit_comparison(self, node: Comparison) -> bool: + if not self._targets_self(node): + return False + return compare(self.value, node.value, node.op, case_exact=self.case_exact) + + def visit_present(self, node: Present) -> bool: + return self._targets_self(node) and is_present(self.value) + + def visit_not(self, node: Not) -> bool: + return not self.visit(node.expr) + + def visit_logical_expr(self, node: LogicalExpr) -> bool: + results = (self.visit(term) for term in node.terms) + return all(results) if node.op == LogicalOperator.and_ else any(results) + + def visit_value_path(self, node: ValuePath) -> bool: + """Refuse a value selection nested in another, which the errata forbid.""" + return False diff --git a/scim2_models/grammar.py b/scim2_models/grammar.py new file mode 100644 index 00000000..ec6ca0ce --- /dev/null +++ b/scim2_models/grammar.py @@ -0,0 +1,264 @@ +import json +from functools import lru_cache +from math import isfinite +from typing import Any +from typing import cast + +from lark import Lark +from lark import Token +from lark import Transformer +from lark import v_args +from lark.exceptions import LarkError +from lark.exceptions import VisitError + +from .exceptions import InvalidFilterException +from .exceptions import InvalidPathException +from .expressions import _ATTR_NAME +from .expressions import _URN +from .expressions import AttrPath +from .expressions import CompareOperator +from .expressions import Comparison +from .expressions import FilterNode +from .expressions import LogicalExpr +from .expressions import LogicalOperator +from .expressions import Not +from .expressions import PathNode +from .expressions import Present +from .expressions import ValuePath + +_GRAMMAR = rf""" +// ---- FILTER, RFC 7644 §3.4.2.2 with errata 4670 and 7322 applied +?filter: or_expr +?or_expr: and_expr (_OR and_expr)+ -> or_expr + | and_expr +?and_expr: not_expr (_AND not_expr)+ -> and_expr + | not_expr +// The published ABNF reads *1"not" "(" FILTER ")" with no space, so not(...) +// parses; errata 7319 would require one and is not applied. +?not_expr: _NOT _LPAR filter _RPAR -> not_expr + | primary +?primary: _LPAR filter _RPAR + | value_path + | attr_exp + +// ---- PATH, RFC 7644 §3.5.2 with errata 7122 applied +?path: ATTR_PATH -> path_attr + | value_path_sub + | attr_exp + +// A valuePath only carries a sub-attribute in a PATCH path, never in a filter, +// where it has to stand as a boolean expression of its own. The ABNF lets the +// attrPath before the brackets carry one too, which the builder refuses: the +// filter between them addresses sub-attributes of that attribute, and SCIM +// has nothing below those. +value_path_sub: ATTR_PATH _LBRACKET val_filter _RBRACKET [_DOT SUB_ATTR] +value_path: ATTR_PATH _LBRACKET val_filter _RBRACKET + +// valFilter is FILTER minus valuePath: full boolean expressions are allowed +// inside the brackets, nested value selections are not (errata 4690 and 7322). +?val_filter: val_or +?val_or: val_and (_OR val_and)+ -> or_expr + | val_and +?val_and: val_not (_AND val_not)+ -> and_expr + | val_not +?val_not: _NOT _LPAR val_filter _RPAR -> not_expr + | val_primary +?val_primary: _LPAR val_filter _RPAR + | attr_exp + +attr_exp: ATTR_PATH PR -> present_exp + | ATTR_PATH COMPARE_OP comp_value -> compare_exp + +?comp_value: STRING -> string_value + | NUMBER -> number_value + | TRUE -> true_value + | FALSE -> false_value + | NULL -> null_value + +// The whole attrPath is a single token: splitting the URN from the attribute +// name is unambiguous once the full string is known, whereas letting the lexer +// decide where the URN ends requires a lookahead that LALR cannot backtrack. +// A single sub-attribute, since the ABNF reads "*1subAttr" and SCIM has no +// nested complex attributes (errata 8415 of RFC 7643). +// Case-insensitive, since a URN namespace identifier is (RFC 8141 section 2). +// The patterns are the ones AttrPath checks its own names and URN against. +ATTR_PATH: /(?:{_URN}:)?{_ATTR_NAME}(?:\.{_ATTR_NAME})?/i +SUB_ATTR: /{_ATTR_NAME}/ + +// Trailing word boundaries are mandatory: without them an attribute named +// "never" lexes as the "ne" operator followed by "ver". +COMPARE_OP.2: /(?:eq|ne|co|sw|ew|gt|lt|ge|le)(?![A-Za-z0-9_-])/i +PR.2: /pr(?![A-Za-z0-9_-])/i +TRUE.2: /true(?![A-Za-z0-9_-])/i +FALSE.2: /false(?![A-Za-z0-9_-])/i +NULL.2: /null(?![A-Za-z0-9_-])/i +_AND.3: /and(?![A-Za-z0-9_-])/i +_OR.3: /or(?![A-Za-z0-9_-])/i +_NOT.3: /not(?![A-Za-z0-9_-])/i + +// Both literals follow the JSON rules the ABNF refers to, escapes included, so +// that an invalid one is a syntax error located by the lexer rather than a +// failure raised later while decoding the token. +STRING: /"(?:[^"\\\x00-\x1F]|\\["\\\/bfnrt]|\\u[0-9A-Fa-f]{{4}})*"/ +NUMBER: /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/ + +_LPAR: "(" +_RPAR: ")" +_LBRACKET: "[" +_RBRACKET: "]" +_DOT: "." + +%ignore /[ \t\f\r\n]+/ +""" + + +def _split_attr_path(raw: str) -> AttrPath: + """Split a raw ``attrPath`` token into its URN, attribute and sub-attribute. + + The URN, when present, is everything up to the last colon. + """ + uri: str | None = None + remainder = raw + + if raw.lower().startswith("urn:"): + uri, _, remainder = raw.rpartition(":") + + attr, _, sub_attr = remainder.partition(".") + return AttrPath(attr=attr, sub_attr=sub_attr or None, uri=uri) + + +def _select_attr_path(raw: str) -> AttrPath: + """Split the ``attrPath`` a value selection applies to. + + :raises ValueError: If it names a sub-attribute, which the ABNF allows and + SCIM gives no meaning to. + """ + attr_path = _split_attr_path(raw) + if attr_path.sub_attr is not None: + raise ValueError( + f"a value selection cannot apply to the sub-attribute {str(attr_path)!r}" + ) + return attr_path + + +@v_args(inline=True) +class _AstBuilder(Transformer[Token, Any]): + """Turn a lark parse tree into the nodes of :mod:`scim2_models.expressions`.""" + + def or_expr(self, *terms: FilterNode) -> FilterNode: + return LogicalExpr(op=LogicalOperator.or_, terms=terms) + + def and_expr(self, *terms: FilterNode) -> FilterNode: + return LogicalExpr(op=LogicalOperator.and_, terms=terms) + + def not_expr(self, expr: FilterNode) -> FilterNode: + return Not(expr=expr) + + def value_path(self, attr_path: Token, val_filter: FilterNode) -> FilterNode: + return ValuePath( + attr_path=_select_attr_path(str(attr_path)), val_filter=val_filter + ) + + def value_path_sub( + self, attr_path: Token, val_filter: FilterNode, sub_attr: Token | None = None + ) -> FilterNode: + return ValuePath( + attr_path=_select_attr_path(str(attr_path)), + val_filter=val_filter, + sub_attr=str(sub_attr) if sub_attr is not None else None, + ) + + def compare_exp(self, attr_path: Token, op: Token, value: Any) -> FilterNode: + return Comparison( + attr_path=_split_attr_path(str(attr_path)), + op=CompareOperator(str(op).lower()), + value=value, + ) + + def present_exp(self, attr_path: Token, _pr: Token) -> FilterNode: + return Present(attr_path=_split_attr_path(str(attr_path))) + + def path_attr(self, attr_path: Token) -> AttrPath: + return _split_attr_path(str(attr_path)) + + def string_value(self, token: Token) -> str: + return cast(str, json.loads(str(token))) + + def number_value(self, token: Token) -> int | float: + raw = str(token) + if not {".", "e", "E"} & set(raw): + return int(raw) + + value = float(raw) + if not isfinite(value): + # An out of range literal would render back as ``Infinity``, which + # the ABNF has no syntax for, so the filter could not be re-parsed. + raise ValueError(f"number out of range: {raw}") + return value + + def true_value(self, _token: Token) -> bool: + return True + + def false_value(self, _token: Token) -> bool: + return False + + def null_value(self, _token: Token) -> None: + return None + + +# One parser for both entry points: the two start rules share a grammar, and +# building their tables together halves the cost of importing this module. +_PARSER = Lark(_GRAMMAR, start=["filter", "path"], parser="lalr") +_BUILDER = _AstBuilder() + + +@lru_cache(maxsize=1024) +def parse_filter(expression: str) -> FilterNode: + """Parse a SCIM filter expression into an abstract syntax tree. + + :param expression: The filter expression, as found in a ``filter`` query + parameter or in :attr:`SearchRequest.filter `. + :returns: The root node of the parsed expression. + :raises InvalidFilterException: If the expression is syntactically invalid. + + >>> from scim2_models.filters import parse_filter + >>> parse_filter('userName eq "bjensen"') + Comparison(attr_path=AttrPath(attr='userName', sub_attr=None, uri=None), op=, value='bjensen') + """ + try: + tree = _PARSER.parse(expression, start="filter") + return cast(FilterNode, _BUILDER.transform(tree)) + except LarkError as exc: + raise InvalidFilterException( + filter=expression, detail=_error_detail(exc) + ) from exc + + +@lru_cache(maxsize=1024) +def parse_path(path: str) -> PathNode: + """Parse a SCIM PATCH path into an abstract syntax tree. + + :param path: The path, as found in :attr:`PatchOperation.path `. + :returns: The parsed path. + :raises InvalidPathException: If the path is syntactically invalid. + + >>> from scim2_models.filters import parse_path + >>> parse_path("name.familyName") + AttrPath(attr='name', sub_attr='familyName', uri=None) + """ + try: + tree = _PARSER.parse(path, start="path") + return cast(PathNode, _BUILDER.transform(tree)) + except LarkError as exc: + raise InvalidPathException(path=path, detail=_error_detail(exc)) from exc + + +def _error_detail(exc: LarkError) -> str: + """Build a human readable message out of a lark failure.""" + if isinstance(exc, VisitError): + return str(exc.orig_exc) + + column = getattr(exc, "column", None) + if column is None: + return "invalid syntax" + return f"invalid syntax at column {column}" diff --git a/scim2_models/resolution.py b/scim2_models/resolution.py new file mode 100644 index 00000000..793ff44d --- /dev/null +++ b/scim2_models/resolution.py @@ -0,0 +1,444 @@ +from dataclasses import dataclass +from dataclasses import replace +from inspect import isclass +from typing import Any +from typing import cast +from typing import get_args + +from pydantic import TypeAdapter +from pydantic import ValidationError + +from .annotations import CaseExact +from .base import BaseModel +from .exceptions import InvalidFilterException +from .exceptions import PathNotFoundException +from .expressions import ORDERING_OPERATORS +from .expressions import STRING_OPERATORS +from .expressions import AttrPath +from .expressions import CompareOperator +from .utils import _find_field_name + +# Python types that RFC7644 §3.4.2.2 forbids comparing with an ordering operator. +_UNORDERABLE_TYPES = (bool, bytes) + +_RESOLVED_ATTRS = "__scim_resolved_attrs__" +"""The attribute a model keeps its resolved paths under.""" + + +def _unwrap_annotated(type_: Any) -> type | None: + """Strip the metadata of an :data:`~typing.Annotated` type. + + A ``binary`` attribute is declared as ``Base64Bytes``, which is an + annotated :class:`bytes` rather than a class of its own. + """ + metadata = getattr(type_, "__metadata__", None) + unwrapped = get_args(type_)[0] if metadata else type_ + return cast("type | None", unwrapped) + + +@dataclass(frozen=True) +class ResolvedAttribute: + """A syntactic attribute path bound to the model it designates. + + Transpilers get everything they need to emit a query out of this: which + table or column, whether a join is required, and whether the comparison + should be case-sensitive. + """ + + model: type[BaseModel] + """The model holding the head attribute, either a resource or an extension.""" + + field_name: str + """The Python name of the head attribute, e.g. ``emails``.""" + + field_type: type | None + """The Python type of the head attribute, e.g. ``Email``.""" + + is_multivalued: bool + """Whether the head attribute holds several values, which usually means a join.""" + + sub_field_name: str | None = None + """The Python name of the sub-attribute, e.g. ``type`` in ``emails.type``.""" + + sub_field_type: type | None = None + """The Python type of the sub-attribute.""" + + case_exact: bool = False + """Whether the compared attribute is case-sensitive.""" + + urn: str = "" + """The fully qualified URN of the compared attribute.""" + + @property + def target_type(self) -> type | None: + """The type of the attribute actually compared, sub-attribute included. + + Annotated types are unwrapped, so a ``binary`` attribute declared as + ``Base64Bytes`` reports :class:`bytes`. + """ + raw = self.sub_field_type if self.sub_field_name else self.field_type + return _unwrap_annotated(raw) + + @property + def target_model(self) -> type[BaseModel] | None: + """The model holding the attribute actually compared.""" + if not self.sub_field_name: + return self.model + if isclass(self.field_type) and issubclass(self.field_type, BaseModel): + return self.field_type + return None + + @property + def target_field_name(self) -> str: + """The Python name of the attribute actually compared.""" + return self.sub_field_name or self.field_name + + def nested_in(self, urn: str) -> "ResolvedAttribute": + """Return the same attribute, qualified by the URN it was resolved under. + + An attribute resolved inside a value selection is resolved against a + complex model, which has no schema of its own, so its URN is only + complete once the URN of the enclosing attribute is known. + """ + return replace(self, urn=f"{urn}.{self.urn}") + + +def attribute_host(obj: Any, resolved: ResolvedAttribute) -> Any: + """Return the object actually holding a resolved attribute. + + An attribute qualified by an extension URN lives on the extension instance, + which hangs off the resource under the extension class name. + + :param obj: The resource the attribute was resolved against. + :param resolved: The attribute to look for. + :returns: The object to read the attribute from, or :data:`None` when the + extension holding it is not set. + """ + from .resources.resource import Extension + + model = resolved.model + if not (isclass(model) and issubclass(model, Extension)): + return obj + if isinstance(obj, model): + return obj + return getattr(obj, model.__name__, None) + + +def _extension_models(model: type[BaseModel]) -> dict[str, type[BaseModel]]: + """Return the extension models of a resource, keyed by schema URN.""" + from .resources.resource import Resource + + if not (isclass(model) and issubclass(model, Resource)): + return {} + return dict(model.get_extension_models()) + + +def _target_model( + model: type[BaseModel], attr_path: AttrPath, *, strict: bool +) -> type[BaseModel] | None: + """Resolve which model an attribute path applies to. + + A path without a URN applies to the resource itself. A qualified path + applies either to the resource or to one of its extensions, and a URN + designating neither is refused when ``strict``. + + :raises PathNotFoundException: If ``strict`` and the URN designates no model. + """ + from .resources.resource import Extension + from .resources.resource import Resource + + if attr_path.uri is None: + return model + + uri = attr_path.uri.lower() + + own_schema = getattr(model, "__schema__", None) if isclass(model) else None + if own_schema and uri == own_schema.lower(): + return model + + for schema, extension_model in _extension_models(model).items(): + if uri == schema.lower(): + return extension_model + + if not strict: + return None + + # RFC7644 §3.10 has a URN qualify the name of an attribute a schema + # declares, and the ABNF of §3.4.2.2 has the path inside a value selection + # name a sub-attribute of the attribute it selects from. A complex + # attribute declares no schema of its own, so nothing resolved against one + # can be qualified. + if not (isclass(model) and issubclass(model, Resource | Extension)): + raise PathNotFoundException( + path=str(attr_path), + detail=f"'{attr_path.attr}' is a sub-attribute, which the schema " + f"'{attr_path.uri}' cannot qualify", + ) + + raise PathNotFoundException(path=str(attr_path), field=attr_path.attr) + + +def resolve_attr_path( + model: type[BaseModel], attr_path: AttrPath, *, strict: bool = True +) -> ResolvedAttribute | None: + """Bind an attribute path to the field it designates on a model. + + :param model: The resource or extension model to resolve against. + :param attr_path: The attribute path to resolve. + :param strict: Whether an unknown attribute raises instead of returning + :data:`None`. + :returns: The resolved attribute, or :data:`None` when it cannot be + resolved and ``strict`` is false. Results are cached and shared, which + is safe since :class:`ResolvedAttribute` is immutable. + :raises PathNotFoundException: If ``strict`` and the attribute is unknown. + """ + # The cache is held by the model rather than by this module, so that it + # cannot outlive the model it describes: a resolved attribute names the + # model it was resolved on, which a cache keyed by that model would keep + # alive for as long as the process runs. + cache: dict[tuple[AttrPath, bool], ResolvedAttribute | None] | None + cache = model.__dict__.get(_RESOLVED_ATTRS) + if cache is None: + cache = {} + setattr(model, _RESOLVED_ATTRS, cache) + + key = (attr_path, strict) + if key not in cache: + cache[key] = _resolve_attr_path(model, attr_path, strict=strict) + return cache[key] + + +def _resolve_attr_path( + model: type[BaseModel], attr_path: AttrPath, *, strict: bool +) -> ResolvedAttribute | None: + """Bind an attribute path, without going through the cache of the model.""" + target = _target_model(model, attr_path, strict=strict) + if target is None: + return None + + field_name = _find_field_name(target, attr_path.attr) + if field_name is None: + if strict: + raise PathNotFoundException(path=str(attr_path), field=attr_path.attr) + return None + + field_type = target.get_field_root_type(field_name) + is_multivalued = target.get_field_multiplicity(field_name) + + sub_field_name: str | None = None + sub_field_type: type | None = None + + if attr_path.sub_attr is not None: + if not (isclass(field_type) and issubclass(field_type, BaseModel)): + if strict: + raise PathNotFoundException( + path=str(attr_path), field=attr_path.sub_attr + ) + return None + + sub_field_name = _find_field_name(field_type, attr_path.sub_attr) + if sub_field_name is None: + if strict: + raise PathNotFoundException( + path=str(attr_path), field=attr_path.sub_attr + ) + return None + + sub_field_type = field_type.get_field_root_type(sub_field_name) + + # A sub-attribute is only resolved on a model, as checked above, so the + # holder is always one. + case_exact_holder = field_type if sub_field_name else target + case_exact = ( + case_exact_holder.get_field_annotation( # type: ignore[union-attr] + sub_field_name or field_name, CaseExact + ) + == CaseExact.true + ) + + return ResolvedAttribute( + model=target, + field_name=field_name, + field_type=field_type, + is_multivalued=is_multivalued, + sub_field_name=sub_field_name, + sub_field_type=sub_field_type, + case_exact=case_exact, + urn=_build_urn(target, attr_path), + ) + + +def _addresses_entry_values(resolved: ResolvedAttribute) -> bool: + """Whether a comparison against this attribute applies to ``value`` instead.""" + return ( + resolved.sub_field_name is None + and resolved.is_multivalued + and isclass(resolved.field_type) + and issubclass(resolved.field_type, BaseModel) + and _find_field_name(resolved.field_type, "value") is not None + ) + + +def resolve_comparison_path( + model: type[BaseModel], attr_path: AttrPath, *, strict: bool = True +) -> ResolvedAttribute | None: + """Bind the attribute a comparison applies to. + + :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` uses ``emails co + "example.com"`` and ``emails.value co "example.org"`` in the same + expression, so a comparison against a multi-valued complex attribute + applies to the ``value`` sub-attribute its entries carry + (:rfc:`RFC7643 §2.4 <7643#section-2.4>`). Presence tests and value + selections keep addressing the attribute itself, since ``pr`` is defined on + "a non-empty node for complex attributes". + + :param model: The resource or extension model to resolve against. + :param attr_path: The attribute path to resolve. + :param strict: Whether an unknown attribute raises instead of returning + :data:`None`. + :returns: The resolved attribute, or :data:`None` when it cannot be + resolved and ``strict`` is false. + :raises PathNotFoundException: If ``strict`` and the attribute is unknown. + + >>> from scim2_models import User + >>> from scim2_models.filters import AttrPath, resolve_comparison_path + + >>> resolve_comparison_path(User, AttrPath("emails")).sub_field_name + 'value' + """ + resolved = resolve_attr_path(model, attr_path, strict=strict) + if resolved is None or not _addresses_entry_values(resolved): + return resolved + + return resolve_attr_path( + model, AttrPath(attr_path.attr, "value", attr_path.uri), strict=strict + ) + + +def resolve_filter_path( + model: type[BaseModel], + attr_path: AttrPath, + *, + strict: bool = True, + for_comparison: bool = False, +) -> ResolvedAttribute | None: + """Bind an attribute path taken from a filter. + + An attribute a model does not declare makes the *filter* invalid rather + than the path, since :rfc:`RFC7644 §3.12 <7644#section-3.12>` defines + ``invalidPath`` for the ``path`` of a PATCH operation, and ``invalidFilter`` + for "the specified attribute and filter comparison combination". + + :param model: The resource or extension model to resolve against. + :param attr_path: The attribute path to resolve. + :param strict: Whether an unknown attribute raises instead of returning + :data:`None`. + :param for_comparison: Whether the path is the one of a comparison, which + follows the ``value`` convention of :func:`resolve_comparison_path`. + :returns: The resolved attribute, or :data:`None` when it cannot be + resolved and ``strict`` is false. + :raises InvalidFilterException: If ``strict`` and the attribute is unknown. + """ + resolve = resolve_comparison_path if for_comparison else resolve_attr_path + try: + return resolve(model, attr_path, strict=strict) + except PathNotFoundException as exc: + raise InvalidFilterException(detail=str(exc)) from exc + + +def _build_urn(model: type[BaseModel], attr_path: AttrPath) -> str: + """Build the fully qualified URN of a resolved attribute.""" + schema = attr_path.uri or ( + getattr(model, "__schema__", None) if isclass(model) else None + ) + suffix = str(AttrPath(attr=attr_path.attr, sub_attr=attr_path.sub_attr)) + return f"{schema}:{suffix}" if schema else suffix + + +def coerce_value( + resolved: ResolvedAttribute, value: Any, op: CompareOperator | None = None +) -> Any: + """Convert a raw comparison value to the Python type of its attribute. + + A filter carries JSON values, but a model holds Python ones: a + ``dateTime`` attribute compared against ``"2011-05-13T04:42:34Z"`` has to + be compared against a :class:`~datetime.datetime`. + + The substring operators are exempt, since they match a fragment rather than + a whole value: ``emails[value co "example"]`` is a legitimate filter even + though ``"example"`` is not a valid email address on its own. + + :param resolved: The attribute the value is compared against. + :param value: The raw value read from the filter. + :param op: The operator the value is used with, when known. + :returns: The value converted to the attribute type, left untouched when + no conversion applies. + :raises InvalidFilterException: If the value is not valid for the attribute. + """ + target_type = resolved.target_type + if value is None or target_type is None or target_type is Any: + return value + + if op is not None and op in STRING_OPERATORS: + return value + + if not isclass(target_type) or issubclass(target_type, BaseModel): + return value + + if isinstance(value, target_type) and not issubclass(target_type, bytes): + return value + + # The annotated form is what carries a decoder, so a base64 operand of a + # binary attribute is decoded rather than taken for raw bytes. + annotated_type = ( + resolved.sub_field_type if resolved.sub_field_name else resolved.field_type + ) + + try: + return TypeAdapter(annotated_type).validate_python(value) + except ValidationError as exc: + raise InvalidFilterException( + detail=f"value {value!r} is not valid for attribute '{resolved.urn}'" + ) from exc + + +def validate_operator(resolved: ResolvedAttribute, op: CompareOperator) -> None: + """Check that an operator may be applied to an attribute. + + :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` requires boolean and binary + attributes to be rejected for the ordering operators. The same is done for + the substring operators, which have no meaning on those types either. + + :param resolved: The attribute the operator is applied to. + :param op: The operator to check. + :raises InvalidFilterException: If the combination is not supported. + """ + target_type = resolved.target_type + if not isclass(target_type) or not issubclass(target_type, _UNORDERABLE_TYPES): + return + + if op not in ORDERING_OPERATORS | STRING_OPERATORS: + return + + kind = "boolean" if issubclass(target_type, bool) else "binary" + raise InvalidFilterException( + detail=f"operator '{op.value}' cannot be applied to the {kind} attribute '{resolved.urn}'" + ) + + +def validate_value_selection(resolved: ResolvedAttribute) -> None: + """Check that a value selection applies to a multi-valued attribute. + + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` defines the ``valuePath`` rule + as selecting "specific values of a complex multi-valued attribute", so an + attribute holding a single value has nothing to select from. + + :param resolved: The attribute the selection applies to. + :raises InvalidFilterException: If the attribute is not multi-valued. + """ + if resolved.is_multivalued: + return + + raise InvalidFilterException( + detail=f"attribute '{resolved.urn}' is not multi-valued and holds no values to select" + ) diff --git a/tests/conftest.py b/tests/conftest.py index a5720817..6afb7687 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,10 @@ import json +import sys import pytest +collect_ignore = [] if sys.version_info >= (3, 14) else ["test_template_strings.py"] + @pytest.fixture def load_sample(): diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index 47f00a2d..c791b927 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -12,6 +12,7 @@ from scim2_models.annotations import Uniqueness from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context +from scim2_models.filters import ScimFilter from scim2_models.path import Path from scim2_models.reference import URI from scim2_models.reference import External @@ -2885,10 +2886,12 @@ def test_a_model_built_at_runtime_is_collected_once_it_is_dropped(): ) reference = weakref.ref(Model) - # The assertion is made on a value that does not name the model, since + # The assertions are made on values that do not name the model, since # pytest keeps the operands of an assertion for as long as the test runs. bound = Path[Model]("label").field_name == "label" + matched = ScimFilter[Model]('label eq "x"').match(Model(label="x")) assert bound + assert matched del Model diff --git a/tests/test_filter_grammar.py b/tests/test_filter_grammar.py new file mode 100644 index 00000000..189596bc --- /dev/null +++ b/tests/test_filter_grammar.py @@ -0,0 +1,546 @@ +from datetime import datetime +from datetime import timezone + +import pytest + +from scim2_models import InvalidFilterException +from scim2_models import InvalidPathException +from scim2_models import ScimFilter +from scim2_models.filters import AttrPath +from scim2_models.filters import CompareOperator +from scim2_models.filters import Comparison +from scim2_models.filters import FilterNode +from scim2_models.filters import FilterVisitor +from scim2_models.filters import LogicalExpr +from scim2_models.filters import LogicalOperator +from scim2_models.filters import Not +from scim2_models.filters import Present +from scim2_models.filters import ValuePath +from scim2_models.filters import parse_filter +from scim2_models.filters import parse_path + + +@pytest.mark.parametrize( + "expression", + [ + 'userName eq "bjensen"', + 'name.familyName co "O\'Malley"', + 'userName sw "J"', + 'urn:ietf:params:scim:schemas:core:2.0:User:userName sw "J"', + "title pr", + 'meta.lastModified gt "2011-05-13T04:42:34Z"', + 'meta.lastModified ge "2011-05-13T04:42:34Z"', + 'meta.lastModified lt "2011-05-13T04:42:34Z"', + 'meta.lastModified le "2011-05-13T04:42:34Z"', + 'title pr and userType eq "Employee"', + 'title pr or userType eq "Intern"', + 'schemas eq "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"', + 'userType eq "Employee" and (emails co "example.com" or emails.value co "example.org")', + 'userType ne "Employee" and not (emails co "example.com" or emails.value co "example.org")', + 'userType eq "Employee" and (emails.type eq "work")', + 'userType eq "Employee" and emails[type eq "work" and value co "@example.com"]', + 'emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"]', + ], +) +def test_the_examples_of_the_rfc_are_parsed(expression): + """Every filter of Figure 2 of §3.4.2.2 has to be accepted.""" + assert parse_filter(expression) is not None + + +@pytest.mark.parametrize( + "attr_name", + [ + "never", + "prime", + "nullable", + "organization", + "andrew", + "equipment", + "lease", + "greatest", + "coworker", + "swimming", + "network", + "notation", + "trueValue", + "presence", + ], +) +def test_attribute_names_starting_like_a_keyword(attr_name): + """An attribute whose name begins with an operator or keyword is not one. + + ``ATTRNAME = ALPHA *(nameChar)`` accepts them all, so the lexer has to + require a word boundary after every keyword. + """ + node = parse_filter(f'{attr_name} eq "x"') + assert node == Comparison(AttrPath(attr_name), CompareOperator.eq, "x") + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ( + "urn:ietf:params:scim:schemas:core:2.0:User:userName pr", + AttrPath("userName", uri="urn:ietf:params:scim:schemas:core:2.0:User"), + ), + ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value pr", + AttrPath( + "manager", + sub_attr="value", + uri="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ), + ), + ( + "URN:IETF:PARAMS:SCIM:SCHEMAS:CORE:2.0:USER:userName pr", + AttrPath("userName", uri="URN:IETF:PARAMS:SCIM:SCHEMAS:CORE:2.0:USER"), + ), + ], +) +def test_urn_is_split_on_its_last_colon(expression, expected): + """A qualified attribute path separates its URN from the attribute name.""" + assert parse_filter(expression) == Present(expected) + + +def test_dollar_prefixed_attribute_name(): + """``$ref`` is accepted even though the published ABNF excludes it. + + Errata 8924 of RFC 7643 corrects ``ATTRNAME`` to allow a leading ``$``. + """ + assert parse_filter("groups.$ref pr") == Present(AttrPath("groups", "$ref")) + + +@pytest.mark.parametrize("expression", ["not (title pr)", "not(title pr)"]) +def test_negation_with_and_without_space(expression): + """A space between ``not`` and its parenthesis is optional. + + The published ABNF forbids it while the examples of the RFC use it, which + errata 7319 corrects. + """ + assert parse_filter(expression) == Not(Present(AttrPath("title"))) + + +@pytest.mark.parametrize( + ("expression", "expected_ops"), + [ + ("a eq 1 and b eq 2 or c eq 3", ["or", "and"]), + ("a eq 1 or b eq 2 and c eq 3", ["or", "and"]), + ], +) +def test_and_binds_tighter_than_or(expression, expected_ops): + """Conjunction binds tighter than disjunction, so ``or`` sits at the root.""" + node = parse_filter(expression) + assert node.op == LogicalOperator(expected_ops[0]) + nested = [term for term in node.terms if isinstance(term, LogicalExpr)] + assert [term.op.value for term in nested] == expected_ops[1:] + + +def test_comparison_binds_tighter_than_logical_operators(): + """Attribute operators are applied before logical ones, per errata 4670.""" + node = parse_filter('title sw "M" and userType eq "Employee"') + assert node == LogicalExpr( + op=LogicalOperator.and_, + terms=( + Comparison(AttrPath("title"), CompareOperator.sw, "M"), + Comparison(AttrPath("userType"), CompareOperator.eq, "Employee"), + ), + ) + + +def test_chained_conjunction_is_flattened(): + """Repeating the same operator yields a single node holding every term.""" + node = parse_filter("a eq 1 and b eq 2 and c eq 3") + assert node.op == LogicalOperator.and_ + assert len(node.terms) == 3 + + +def test_grouping_overrides_precedence(): + """Parentheses make a disjunction the operand of a conjunction.""" + node = parse_filter("(a eq 1 or b eq 2) and c eq 3") + assert node.op == LogicalOperator.and_ + assert node.terms[0].op == LogicalOperator.or_ + + +def test_value_path_accepts_a_full_boolean_expression(): + """Brackets hold conjunctions, disjunctions, negations and parentheses. + + The published ABNF restricts ``valFilter`` in a way errata 4690 and 7322 + correct, the latter allowing nested logical expressions. + """ + node = parse_filter('emails[type eq "work" or (type eq "home" and primary pr)]') + assert isinstance(node, ValuePath) + assert node.attr_path == AttrPath("emails") + assert node.val_filter.op == LogicalOperator.or_ + + +def test_value_path_accepts_a_negation(): + node = parse_filter('emails[not (type eq "work")]') + assert node.val_filter == Not( + Comparison(AttrPath("type"), CompareOperator.eq, "work") + ) + + +@pytest.mark.parametrize( + "expression", + [ + 'emails[type eq "work" and emails[type eq "home"]]', + 'emails[emails[type eq "work"].value co "x"]', + ], +) +def test_nested_value_path_is_rejected(expression): + """A value selection cannot contain another one. + + The published ABNF allows it through ``logExp``, which errata 4690 + identifies as unintended. + """ + with pytest.raises(InvalidFilterException): + parse_filter(expression) + + +@pytest.mark.parametrize( + "expression", + ['emails[type eq "work"].value', 'emails[type eq "work"].value eq "x"'], +) +def test_sub_attribute_after_value_path_is_rejected_in_a_filter(expression): + """Only a PATCH path may follow a value selection with a sub-attribute.""" + with pytest.raises(InvalidFilterException): + parse_filter(expression) + + +@pytest.mark.parametrize( + "expression", + [ + 'emails.type[type eq "work"]', + 'urn:ietf:params:scim:schemas:core:2.0:User:emails.type[type eq "work"]', + 'emails.type[type eq "work"] and userName pr', + ], +) +def test_sub_attribute_before_value_path_is_rejected(expression): + """``valuePath = attrPath "[" valFilter "]"`` lets the attrPath name a sub-attribute. + + The filter between the brackets addresses the sub-attributes of the + selected attribute, and a sub-attribute has none of its own, so the form + is left without a meaning to give it. + """ + with pytest.raises( + InvalidFilterException, match="cannot apply to the sub-attribute" + ): + parse_filter(expression) + + +def test_sub_attribute_before_value_path_is_rejected_in_a_patch_path(): + """A selection applies to an attribute, in a path as much as in a filter.""" + with pytest.raises(InvalidPathException, match="cannot apply to the sub-attribute"): + parse_path('emails.type[type eq "work"].value') + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('x eq "text"', "text"), + ("x eq true", True), + ("x eq false", False), + ("x eq null", None), + ("x eq 42", 42), + ("x eq -42", -42), + ("x eq 4.5", 4.5), + ("x eq -2.5e3", -2500.0), + ("x eq 0", 0), + (r'x eq "with \" quote"', 'with " quote'), + (r'x eq "with \\ backslash"', "with \\ backslash"), + (r'x eq "tab\there"', "tab\there"), + ], +) +def test_comparison_values_follow_json_syntax(expression, expected): + """``compValue`` is built on the JSON rules for values.""" + assert parse_filter(expression).value == expected + + +@pytest.mark.parametrize( + "operator", + ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le"], +) +def test_every_comparison_operator_is_parsed(operator): + node = parse_filter(f'x {operator} "v"') + assert node.op == CompareOperator(operator) + + +@pytest.mark.parametrize( + "expression", + [ + 'userName EQ "x"', + 'userName Eq "x"', + 'a eq "x" AND b pr', + 'a eq "x" Or b pr', + "NOT (a pr)", + "a PR", + ], +) +def test_keywords_are_case_insensitive(expression): + """Operators and keywords ignore case, as required by §3.4.2.2.""" + assert parse_filter(expression) is not None + + +@pytest.mark.parametrize( + "expression", + [ + 'userName eq "x"', + 'userName eq"x"', + ' userName eq "x" ', + 'emails[ type eq "work" ]', + ], +) +def test_surrounding_whitespace_is_tolerated(expression): + """Servers receive filters with irregular spacing, which stays acceptable.""" + assert parse_filter(expression) is not None + + +@pytest.mark.parametrize( + "expression", + [ + "", + " ", + "userName", + "userName eq", + 'eq "x"', + "emails[]", + "a eq 1 and", + "and a eq 1", + "a eq 1)", + "(a eq 1", + 'a eq "unterminated', + "a @ 1", + "1a eq 2", + "a..b eq 1", + "a.b.c eq 1", + ], +) +def test_malformed_filters_are_rejected(expression): + with pytest.raises(InvalidFilterException): + parse_filter(expression) + + +def test_a_string_with_an_invalid_escape_is_rejected(): + """``compValue`` follows the JSON rules, which only allow known escapes. + + An unknown one is a syntax error located like any other, rather than a + failure raised later while decoding the string. + """ + with pytest.raises(InvalidFilterException) as exc_info: + parse_filter(r'userName eq "a\q"') + assert exc_info.value.detail == "invalid syntax at column 13" + + +def test_a_path_string_with_an_invalid_escape_is_rejected(): + with pytest.raises(InvalidPathException) as exc_info: + parse_path(r'emails[type eq "a\q"]') + assert exc_info.value.detail == "invalid syntax at column 16" + + +def test_rejection_reports_the_offending_column(): + """A parse failure says where it happened, so a server can explain itself.""" + with pytest.raises(InvalidFilterException) as exc_info: + parse_filter('userName eq "x" and') + assert "column" in exc_info.value.detail + + +def test_rejection_carries_the_offending_filter(): + with pytest.raises(InvalidFilterException) as exc_info: + parse_filter("nonsense @") + assert exc_info.value.filter == "nonsense @" + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("members", AttrPath("members")), + ("name.familyName", AttrPath("name", "familyName")), + ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + AttrPath( + "employeeNumber", + uri="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ), + ), + ], +) +def test_attribute_paths_are_valid_patch_paths(path, expected): + assert parse_path(path) == expected + + +def test_value_path_carries_its_sub_attribute_in_a_patch_path(): + """``PATH = attrPath / valuePath [subAttr] / attrExp``.""" + node = parse_path('members[value eq "2819c223"].displayName') + assert node == ValuePath( + attr_path=AttrPath("members"), + val_filter=Comparison(AttrPath("value"), CompareOperator.eq, "2819c223"), + sub_attr="displayName", + ) + + +def test_value_path_without_sub_attribute_is_a_valid_patch_path(): + node = parse_path('addresses[type eq "work"]') + assert isinstance(node, ValuePath) + assert node.sub_attr is None + + +def test_bare_comparison_is_a_valid_patch_path(): + """Errata 7122 adds ``attrExp``, the only way to target a scalar list value.""" + node = parse_path('schemas eq "urn:ietf:params:scim:schemas:core:2.0:User"') + assert node == Comparison( + AttrPath("schemas"), + CompareOperator.eq, + "urn:ietf:params:scim:schemas:core:2.0:User", + ) + + +def test_bare_presence_is_a_valid_patch_path(): + assert parse_path("title pr") == Present(AttrPath("title")) + + +@pytest.mark.parametrize( + "path", + [ + "", + 'emails[type eq "work"] and active eq true', + 'not (emails[type eq "work"])', + '(emails[type eq "work"])', + "name..familyName", + "a.b.c", + "123invalid", + "invalid@path", + ], +) +def test_malformed_patch_paths_are_rejected(path): + """A PATCH path holds no boolean expression at its root.""" + with pytest.raises(InvalidPathException): + parse_path(path) + + +def test_path_rejection_carries_the_offending_path(): + with pytest.raises(InvalidPathException) as exc_info: + parse_path("name..familyName") + assert exc_info.value.path == "name..familyName" + + +@pytest.mark.parametrize( + "expression", + [ + 'userName eq "bjensen"', + "title pr", + 'emails[type eq "work" or (type eq "home" and value ew "@example.com")]', + 'title pr or userType eq "Intern" and active eq true', + "(a eq 1 or b eq 2) and c eq 3", + "a eq 1 and (b eq 2 or c eq 3)", + "not (a eq 1) and b pr", + "x eq null", + "y eq false", + "z eq -2.5", + r'display eq "with \" quote"', + 'urn:ietf:params:scim:schemas:core:2.0:User:userName eq "x"', + ], +) +def test_rendering_a_filter_yields_an_equivalent_filter(expression): + """Rendering then reparsing a filter gives back the same tree.""" + tree = parse_filter(expression) + assert parse_filter(str(tree)) == tree + + +@pytest.mark.parametrize( + "path", + [ + "members", + "name.familyName", + 'members[value eq "abc"].displayName', + 'addresses[type eq "work"]', + 'schemas eq "urn:x:y:z"', + ], +) +def test_rendering_a_path_yields_an_equivalent_path(path): + tree = parse_path(path) + assert parse_path(str(tree)) == tree + + +def test_nodes_are_hashable(): + """Immutability lets a node be cached or used as a dictionary key.""" + node = parse_filter('userName eq "x"') + assert {node: "value"}[parse_filter('userName eq "x"')] == "value" + + +def test_visiting_an_unknown_node_type_is_rejected(): + with pytest.raises(TypeError, match="Unsupported filter node"): + FilterVisitor().visit(FilterNode()) + + +def test_a_number_out_of_range_is_rejected(): + """``1e400`` reads as an infinity, which the ABNF has no syntax for.""" + with pytest.raises(InvalidFilterException, match="number out of range"): + parse_filter("userName eq 1e400") + + assert parse_filter("userName eq 1e300").value == 1e300 + + +def test_rendering_a_value_json_cannot_express_is_rejected(): + """A node built by hand can hold a float no filter could carry.""" + node = Comparison(AttrPath("userName"), CompareOperator.eq, float("inf")) + with pytest.raises(ValueError, match="not JSON compliant"): + str(node) + + +def test_rendering_a_value_no_filter_can_carry_is_rejected(): + node = Comparison(AttrPath("userName"), CompareOperator.eq, object()) + with pytest.raises(TypeError, match="not a value a filter can carry"): + str(node) + + +def test_rendering_a_datetime_yields_the_string_form_of_a_datetime(): + """A node built by hand can hold a datetime, which a filter carries as a string.""" + when = datetime(2011, 5, 13, 4, 42, 34, tzinfo=timezone.utc) + node = Comparison(AttrPath("meta", "lastModified"), CompareOperator.gt, when) + assert str(node) == 'meta.lastModified gt "2011-05-13T04:42:34+00:00"' + + +@pytest.mark.parametrize( + ("value", "literal"), + [ + ("bjensen", '"bjensen"'), + ( + 'x" or userName pr or userName eq "y', + r'"x\" or userName pr or userName eq \"y"', + ), + ("back\\slash", r'"back\\slash"'), + (18, "18"), + (-2.5, "-2.5"), + (True, "true"), + (None, "null"), + ], +) +def test_quoting_a_value_yields_a_literal_the_grammar_reads_back(value, literal): + assert ScimFilter.quote(value) == literal + assert parse_filter(f"userName eq {literal}").value == value + + +@pytest.mark.parametrize( + "name", + ['userName eq "admin" or userName', "", "1abc", "name.given", 'a"b', "foo$bar"], +) +def test_an_attribute_path_refuses_what_is_not_a_name(name): + """A node built by hand carries a name the grammar reads as one, or nothing.""" + with pytest.raises(ValueError, match="is not an attribute name"): + AttrPath(name) + + +def test_an_attribute_path_refuses_a_sub_attribute_that_is_not_a_name(): + with pytest.raises(ValueError, match="is not an attribute name"): + AttrPath("emails", sub_attr='type eq "work"') + + +def test_an_attribute_path_refuses_a_uri_that_is_not_a_urn(): + with pytest.raises(ValueError, match="is not a schema URN"): + AttrPath("userName", uri="https://example.com/schemas/User") + + +def test_an_attribute_path_accepts_a_urn_in_any_case(): + """A URN namespace identifier is case-insensitive, per RFC 8141 §2.""" + attr_path = AttrPath("userName", uri="URN:IETF:params:scim:schemas:core:2.0:User") + assert parse_filter(f"{attr_path} pr") == Present(attr_path) diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py new file mode 100644 index 00000000..2bdf54d8 --- /dev/null +++ b/tests/test_filter_semantics.py @@ -0,0 +1,1153 @@ +import datetime +import re +from typing import Annotated +from typing import Any + +import pytest +from lark.exceptions import LarkError +from pydantic import BaseModel as PydanticBaseModel +from pydantic import ValidationError + +from scim2_models import EnterpriseUser +from scim2_models import Extension +from scim2_models import Group +from scim2_models import InvalidFilterException +from scim2_models import Name +from scim2_models import Path +from scim2_models import PathNotFoundException +from scim2_models import Required +from scim2_models import ResolvedAttribute +from scim2_models import Schema +from scim2_models import ScimFilter +from scim2_models import User +from scim2_models.filters import AttrPath +from scim2_models.filters import CompareOperator +from scim2_models.filters import Comparison +from scim2_models.filters import FilterVisitor +from scim2_models.filters import LogicalExpr +from scim2_models.filters import LogicalOperator +from scim2_models.filters import Not +from scim2_models.filters import Present +from scim2_models.filters import ValuePath +from scim2_models.filters import coerce_value +from scim2_models.filters import compare +from scim2_models.filters import is_present +from scim2_models.filters import parse_filter +from scim2_models.filters import resolve_attr_path +from scim2_models.filters import resolve_comparison_path +from scim2_models.grammar import _error_detail +from scim2_models.resources.user import Email + +COMPOSED = "Jos\N{LATIN SMALL LETTER E WITH ACUTE}" +DECOMPOSED = "Jose\N{COMBINING ACUTE ACCENT}" +DECOMPOSED_SUFFIX = "se\N{COMBINING ACUTE ACCENT}" + +UserWithExtension = User[EnterpriseUser] + + +@pytest.fixture +def user(): + user = UserWithExtension( + user_name="BJensen", + display_name="Barbara Jensen", + title="Manager", + active=True, + emails=[ + {"type": "work", "value": "bjensen@example.com", "primary": True}, + {"type": "home", "value": "babs@home.net", "primary": False}, + ], + ) + user[EnterpriseUser] = EnterpriseUser( + employee_number="701984", manager={"value": "26118915"} + ) + return user + + +# --- Resolution --- + + +def test_resolution_exposes_what_a_transpiler_needs(): + """A resolved attribute says which field, which type and which casing.""" + resolved = resolve_attr_path(User, AttrPath("emails", "type")) + assert resolved.model is User + assert resolved.field_name == "emails" + assert resolved.sub_field_name == "type" + assert resolved.is_multivalued is True + assert resolved.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.type" + + +def test_resolution_of_a_simple_attribute(): + resolved = resolve_attr_path(User, AttrPath("userName")) + assert resolved.field_name == "user_name" + assert resolved.field_type is str + assert resolved.is_multivalued is False + assert resolved.sub_field_name is None + assert resolved.target_field_name == "user_name" + assert resolved.target_model is User + + +def test_resolution_targets_the_sub_attribute_when_there_is_one(): + resolved = resolve_attr_path(User, AttrPath("name", "familyName")) + assert resolved.target_field_name == "family_name" + assert resolved.target_type is str + assert resolved.target_model is not User + + +def test_resolution_matches_attribute_names_case_insensitively(): + """§3.4.2.2 makes attribute names case-insensitive.""" + assert resolve_attr_path(User, AttrPath("USERNAME")).field_name == "user_name" + + +def test_resolution_of_an_attribute_qualified_by_the_resource_schema(): + resolved = resolve_attr_path( + User, AttrPath("userName", uri="urn:ietf:params:scim:schemas:core:2.0:User") + ) + assert resolved.field_name == "user_name" + + +def test_resolution_of_an_extension_attribute(): + resolved = resolve_attr_path( + UserWithExtension, + AttrPath( + "employeeNumber", + uri="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ), + ) + assert resolved.model is EnterpriseUser + assert resolved.field_name == "employee_number" + + +def test_resolution_reads_case_exactness_from_the_annotations(): + """A reference attribute is case-exact, per §2.3.7 and errata 6001.""" + assert resolve_attr_path(User, AttrPath("profileUrl")).case_exact is True + assert resolve_attr_path(User, AttrPath("userName")).case_exact is False + + +def test_resolution_reads_case_exactness_of_a_sub_attribute(): + assert resolve_attr_path(Group, AttrPath("members", "value")).case_exact is True + assert resolve_attr_path(Group, AttrPath("members", "display")).case_exact is False + + +def test_resolution_of_an_unknown_attribute_raises(): + with pytest.raises(PathNotFoundException): + resolve_attr_path(User, AttrPath("nonexistent")) + + +def test_resolution_of_an_unknown_attribute_is_silent_when_tolerant(): + """§3.4.2.1 has an unparameterised request evaluate an unknown attribute to false.""" + assert resolve_attr_path(User, AttrPath("nonexistent"), strict=False) is None + + +def test_resolution_of_an_unknown_sub_attribute_raises(): + with pytest.raises(PathNotFoundException): + resolve_attr_path(User, AttrPath("name", "nonexistent")) + + +def test_resolution_of_an_unknown_sub_attribute_is_silent_when_tolerant(): + assert resolve_attr_path(User, AttrPath("name", "nope"), strict=False) is None + + +def test_resolution_of_a_sub_attribute_on_a_simple_attribute_raises(): + """``userName`` holds a string, which has no sub-attribute.""" + with pytest.raises(PathNotFoundException): + resolve_attr_path(User, AttrPath("userName", "sub")) + + +def test_resolution_of_a_sub_attribute_on_a_simple_attribute_is_silent_when_tolerant(): + assert resolve_attr_path(User, AttrPath("userName", "sub"), strict=False) is None + + +def test_resolution_of_an_unknown_schema_raises(): + with pytest.raises(PathNotFoundException): + resolve_attr_path(User, AttrPath("attr", uri="urn:unknown:schema")) + + +def test_resolution_of_an_unknown_schema_is_silent_when_tolerant(): + assert ( + resolve_attr_path(User, AttrPath("attr", uri="urn:x:y"), strict=False) is None + ) + + +def test_resolution_of_a_qualified_path_against_a_complex_attribute_raises(): + """A complex attribute declares no schema of its own to qualify against. + + §3.10 has a URN qualify the name of an attribute a schema declares, and the + sub-attributes it holds are reached through the dot notation that follows + it, so a URN designates no model here. + """ + with pytest.raises(PathNotFoundException, match="cannot qualify"): + resolve_attr_path(Email, AttrPath("type", uri="urn:x:y")) + + +def test_resolution_of_a_qualified_path_against_a_complex_attribute_is_silent_when_tolerant(): + """An unparameterised filter resolves this way, and matches nothing instead.""" + assert ( + resolve_attr_path(Email, AttrPath("type", uri="urn:x:y"), strict=False) is None + ) + + +# --- The value convention --- + + +def test_a_comparison_without_a_sub_attribute_resolves_to_the_entry_values(): + """§3.4.2.2 writes ``emails co`` and ``emails.value co`` in one expression. + + A comparison against a multi-valued complex attribute applies to the + ``value`` sub-attribute its entries carry. + """ + resolved = resolve_comparison_path(User, AttrPath("emails")) + assert resolved.field_name == "emails" + assert resolved.sub_field_name == "value" + assert resolved.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + + +def test_the_value_convention_carries_the_case_exactness_of_the_sub_attribute(): + """``members.value`` is case-exact where ``members`` is not.""" + assert resolve_comparison_path(Group, AttrPath("members")).case_exact is True + + +@pytest.mark.parametrize( + ("model", "attr_path"), + [ + pytest.param(User, AttrPath("emails", "type"), id="explicit sub-attribute"), + pytest.param(User, AttrPath("name"), id="single-valued complex attribute"), + pytest.param(User, AttrPath("schemas"), id="list of scalars"), + pytest.param(Schema, AttrPath("attributes"), id="entries without a value"), + ], +) +def test_the_value_convention_leaves_other_attributes_alone(model, attr_path): + assert resolve_comparison_path(model, attr_path) == resolve_attr_path( + model, attr_path + ) + + +def test_the_value_convention_tolerates_an_unknown_attribute(): + assert resolve_comparison_path(User, AttrPath("nope"), strict=False) is None + + +def test_comparing_a_multi_valued_complex_attribute_matches_its_entry_values(): + """The example of Figure 2 of §3.4.2.2 has to match.""" + user = User(user_name="x", emails=[{"type": "work", "value": "bj@example.com"}]) + assert ScimFilter[User]('emails co "example.com"').match(user) + assert ScimFilter[User]('emails eq "bj@example.com"').match(user) + assert not ScimFilter[User]('emails co "example.org"').match(user) + + +def test_presence_of_a_multi_valued_complex_attribute_tests_the_node(): + """``pr`` matches "a non-empty node for complex attributes", not its values. + + An entry without a ``value`` is still a node, so the convention that + applies to comparisons must not apply here. + """ + user = User(user_name="x", emails=[{"type": "work"}]) + assert ScimFilter[User]("emails pr").match(user) + assert not ScimFilter[User]("emails.value pr").match(user) + + +def test_validating_a_comparison_against_entry_values_checks_the_sub_attribute(): + """A binary ``value`` refuses the substring operators, however addressed.""" + with pytest.raises(InvalidFilterException, match="binary"): + ScimFilter[User]('x509Certificates co "a"')._validate_semantics() + + +# --- Coercion --- + + +def test_a_date_time_operand_is_coerced(): + """A filter carries JSON, a model holds Python types.""" + user = User(user_name="x") + user.meta = {"last_modified": datetime.datetime(2011, 5, 13, 4, 42, 34)} + assert ScimFilter[User]('meta.lastModified gt "2010-01-01T00:00:00"').match(user) + assert not ScimFilter[User]('meta.lastModified gt "2012-01-01T00:00:00"').match( + user + ) + + +def test_an_integer_operand_is_coerced_from_a_string(): + """Comparing against the wrong JSON type still works when convertible.""" + resolved = resolve_attr_path(User, AttrPath("userName")) + + assert coerce_value(resolved, "already a string") == "already a string" + + +def test_an_operand_that_does_not_fit_its_attribute_is_rejected(): + with pytest.raises(InvalidFilterException, match="is not valid for attribute"): + ScimFilter[User]("userName eq 3")._validate_semantics() + + +@pytest.mark.parametrize( + "expression", + ['emails.value eq "notanemail"', 'emails[value eq "notanemail"]'], +) +def test_a_rejected_operand_names_the_whole_attribute(expression): + """A sub-attribute is named by its full URN however it was addressed. + + Inside a value selection it is resolved against a complex model, which has + no schema of its own, so the enclosing attribute has to complete it. + """ + with pytest.raises(InvalidFilterException) as exc_info: + ScimFilter[User](expression)._validate_semantics() + assert ( + "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + in exc_info.value.detail + ) + + +def test_a_rejected_operator_names_the_whole_attribute(): + with pytest.raises(InvalidFilterException) as exc_info: + ScimFilter[User]('emails[primary co "x"]')._validate_semantics() + assert ( + "urn:ietf:params:scim:schemas:core:2.0:User:emails.primary" + in exc_info.value.detail + ) + + +def test_a_value_selection_evaluated_against_a_model_names_the_whole_attribute(): + """The evaluator reports the same URN as the validator.""" + user = User(user_name="x", emails=[{"value": "bjensen@example.com"}]) + with pytest.raises(InvalidFilterException) as exc_info: + ScimFilter[User]('emails[primary co "x"]').match(user) + assert ( + "urn:ietf:params:scim:schemas:core:2.0:User:emails.primary" + in exc_info.value.detail + ) + + +def test_a_substring_operand_is_not_coerced(): + """``co``, ``sw`` and ``ew`` match a fragment, not a whole value. + + ``emails[value co "example"]`` is legitimate even though ``"example"`` is + not a valid email address on its own. + """ + user = User(user_name="x", emails=[{"value": "bjensen@example.com"}]) + assert ScimFilter[User]('emails[value co "example"]').match(user) + assert ScimFilter[User]('emails[value sw "bjensen"]').match(user) + assert ScimFilter[User]('emails[value ew ".com"]').match(user) + + +def test_a_null_operand_is_left_alone(): + + resolved = resolve_attr_path(User, AttrPath("userName")) + assert coerce_value(resolved, None) is None + + +# --- Operator validity --- + + +@pytest.mark.parametrize("operator", ["gt", "ge", "lt", "le"]) +def test_ordering_a_boolean_attribute_is_rejected(operator): + """§3.4.2.2 requires boolean attributes to fail the ordering operators.""" + with pytest.raises(InvalidFilterException, match="boolean"): + ScimFilter[User](f"active {operator} true")._validate_semantics() + + +@pytest.mark.parametrize("operator", ["co", "sw", "ew"]) +def test_substring_matching_a_boolean_attribute_is_rejected(operator): + with pytest.raises(InvalidFilterException, match="boolean"): + ScimFilter[User](f'active {operator} "x"')._validate_semantics() + + +@pytest.mark.parametrize("operator", ["eq", "ne"]) +def test_comparing_a_boolean_attribute_for_equality_is_allowed(operator): + ScimFilter[User](f"active {operator} true")._validate_semantics() + + +def test_ordering_a_binary_attribute_is_rejected(): + with pytest.raises(InvalidFilterException, match="binary"): + ScimFilter[User]('x509Certificates.value gt "abc"')._validate_semantics() + + +# --- Evaluation --- + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('userName eq "bjensen"', True), + ('userName eq "BJENSEN"', True), + ('userName eq "other"', False), + ('userName ne "other"', True), + ('userName co "jen"', True), + ('userName sw "bj"', True), + ('userName ew "sen"', True), + ('userName gt "a"', True), + ('userName lt "a"', False), + ('userName ge "bjensen"', True), + ('userName le "bjensen"', True), + ("title pr", True), + ("nickName pr", False), + ("active eq true", True), + ("active eq false", False), + ], +) +def test_comparison_against_a_single_valued_attribute(user, expression, expected): + assert ScimFilter[UserWithExtension](expression).match(user) is expected + + +def test_comparison_is_case_insensitive_by_default(): + """A string attribute is case-insensitive unless annotated case-exact.""" + user = User(user_name="BJensen") + assert ScimFilter[User]('userName eq "bjensen"').match(user) + + +def test_comparison_is_case_sensitive_on_a_case_exact_attribute(): + """``members.value`` references an id, which is case-exact per errata 8472.""" + group = Group(display_name="G", members=[{"value": "AbC"}]) + assert ScimFilter[Group]('members[value eq "AbC"]').match(group) + assert not ScimFilter[Group]('members[value eq "abc"]').match(group) + + +def test_case_insensitive_comparison_folds_a_letter_expanding_to_two(): + """Case folding expands the sharp s to a double s, where lowercasing does not.""" + user = User(display_name="Stra\N{LATIN SMALL LETTER SHARP S}e") + assert ScimFilter[User]('displayName eq "STRASSE"').match(user) + + +def test_case_insensitive_comparison_folds_the_greek_final_sigma(): + """A final and a medial sigma are the same letter and fold to the same value.""" + user = User(display_name="\u039f\u0394\u039f\u03a3") + assert ScimFilter[User]('displayName eq "\u03bf\u03b4\u03bf\u03c3"').match(user) + assert ScimFilter[User]('displayName eq "\u03bf\u03b4\u03bf\u03c2"').match(user) + + +def test_comparison_matches_a_canonically_equivalent_operand(): + """A precomposed and a decomposed accent denote the same text.""" + user = User(display_name=COMPOSED) + assert ScimFilter[User](f'displayName eq "{DECOMPOSED}"').match(user) + + +def test_a_case_exact_comparison_matches_a_canonically_equivalent_operand(): + """Case exactness constrains the casing, not the normalization form.""" + assert compare(COMPOSED, DECOMPOSED, CompareOperator.eq, case_exact=True) + assert not compare( + COMPOSED.upper(), DECOMPOSED, CompareOperator.eq, case_exact=True + ) + + +@pytest.mark.parametrize( + "operand", + [ + f'co "{DECOMPOSED_SUFFIX} Jen"', + f'sw "{DECOMPOSED.lower()}"', + 'ew "JENSEN"', + ], +) +def test_substring_operators_prepare_both_operands(operand): + user = User(display_name=f"{COMPOSED} Jensen") + assert ScimFilter[User](f"displayName {operand}").match(user) + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('emails.type eq "work"', True), + ('emails.type eq "home"', True), + ('emails.type eq "other"', False), + ('emails.value co "example"', True), + ], +) +def test_comparison_against_a_multi_valued_attribute_matches_any_value( + user, expression, expected +): + """§3.4.2.2: the filter matches if any of the values matches.""" + assert ScimFilter[UserWithExtension](expression).match(user) is expected + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('emails.type ne "work"', False), + ('emails.type ne "home"', False), + ('emails.type ne "other"', True), + ], +) +def test_negated_comparison_against_a_multi_valued_attribute_is_universal( + user, expression, expected +): + """The RFC leaves ``ne`` on a multi-valued attribute undefined. + + The universal reading is used: no value equals the operand, so that it + agrees with negating the equality. + """ + assert ScimFilter[UserWithExtension](expression).match(user) is expected + + +def test_presence_of_a_multi_valued_sub_attribute(user): + assert ScimFilter[UserWithExtension]("emails.value pr").match(user) + + +def test_presence_of_an_unset_attribute_does_not_match(): + assert not ScimFilter[User]("title pr").match(User(user_name="x")) + + +def test_presence_of_an_empty_list_does_not_match(): + assert not ScimFilter[User]("emails pr").match(User(user_name="x", emails=[])) + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('emails[type eq "work"]', True), + ('emails[type eq "other"]', False), + ('emails[type eq "work" and primary eq true]', True), + ('emails[type eq "home" and primary eq true]', False), + ('emails[type eq "work" or type eq "home"]', True), + ('emails[not (type eq "work")]', True), + ('emails[value ew "@home.net"]', True), + ], +) +def test_value_selection(user, expression, expected): + assert ScimFilter[UserWithExtension](expression).match(user) is expected + + +def test_value_selection_on_an_unset_attribute_does_not_match(): + assert not ScimFilter[User]('emails[type eq "work"]').match(User(user_name="x")) + + +def test_value_selection_on_a_single_valued_attribute_is_invalid(): + """§3.5.2 defines a selection over a complex multi-valued attribute only.""" + user = User(user_name="x", name={"family_name": "Jensen"}) + with pytest.raises(InvalidFilterException, match="not multi-valued"): + ScimFilter[User]('name[familyName eq "Jensen"]').match(user) + + with pytest.raises(InvalidFilterException, match="not multi-valued"): + ScimFilter[User]('name[familyName eq "Jensen"]')._validate_semantics() + + +def test_value_selection_on_a_single_valued_attribute_names_it(): + with pytest.raises(InvalidFilterException, match="userName"): + ScimFilter[User]('userName[value eq "x"]')._validate_semantics() + + +def test_value_selection_on_a_scalar_list_uses_the_value_convention(): + """A multi-valued attribute that is not complex has no sub-attribute. + + Implementations conventionally address its entries through ``value``. + """ + user = User(user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User"]) + assert ScimFilter[User]( + 'schemas[value eq "urn:ietf:params:scim:schemas:core:2.0:User"]' + ).match(user) + assert not ScimFilter[User]('schemas[value eq "urn:other"]').match(user) + + +def test_value_selection_on_a_scalar_list_ignores_other_attribute_names(): + user = User( + user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:x"] + ) + assert not ScimFilter[User]('schemas[type eq "urn:x"]').match(user) + + +def test_presence_within_a_value_selection_on_a_scalar_list(): + user = User( + user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:x"] + ) + assert ScimFilter[User]("schemas[value pr]").match(user) + + +def test_boolean_operators_within_a_value_selection_on_a_scalar_list(): + user = User( + user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:x"] + ) + assert ScimFilter[User]('schemas[value eq "urn:x" or value eq "urn:y"]').match(user) + assert ScimFilter[User]('schemas[not (value eq "urn:y")]').match(user) + assert not ScimFilter[User]('schemas[value eq "urn:x" and value eq "urn:y"]').match( + user + ) + + +def test_a_value_selection_on_an_empty_scalar_list_does_not_match(): + assert not ScimFilter[User]("schemas[value pr]").match( + User(user_name="x", schemas=[]) + ) + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ('userName eq "bjensen" and active eq true', True), + ('userName eq "bjensen" and active eq false', False), + ('userName eq "other" or title pr', True), + ('userName eq "other" or nickName pr', False), + ('not (userName eq "bjensen")', False), + ('not (userName eq "other")', True), + ("userName pr and title pr and active eq true", True), + ], +) +def test_logical_composition(user, expression, expected): + assert ScimFilter[UserWithExtension](expression).match(user) is expected + + +def test_comparison_against_an_extension_attribute(user): + assert ScimFilter[UserWithExtension]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber " + 'eq "701984"' + ).match(user) + + +def test_comparison_against_an_extension_sub_attribute(user): + assert ScimFilter[UserWithExtension]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value " + 'eq "26118915"' + ).match(user) + + +def test_comparison_against_an_unset_extension_does_not_match(): + user = UserWithExtension(user_name="x") + assert not ScimFilter[UserWithExtension]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr" + ).match(user) + + +def test_comparison_against_an_unset_complex_attribute_does_not_match(): + assert not ScimFilter[User]('name.familyName eq "Jensen"').match( + User(user_name="x") + ) + + +def test_evaluation_of_an_unknown_attribute_does_not_match_by_default(): + """Tolerance is the default, so an unknown attribute simply does not match.""" + assert not ScimFilter[User]('nonexistent eq "x"').match(User(user_name="x")) + + +def test_evaluation_of_an_unknown_attribute_raises_when_strict(): + with pytest.raises(InvalidFilterException): + ScimFilter[User]('nonexistent eq "x"').match(User(user_name="x"), strict=True) + + +def test_presence_of_an_unknown_attribute_does_not_match(): + assert not ScimFilter[User]("nonexistent pr").match(User(user_name="x")) + + +def test_value_selection_on_an_unknown_attribute_does_not_match(): + assert not ScimFilter[User]('nonexistent[type eq "x"]').match(User(user_name="x")) + + +# --- The filter type itself --- + + +def test_a_filter_behaves_like_the_string_it_came_from(): + expression = 'userName eq "bjensen"' + assert ScimFilter(expression) == expression + assert str(ScimFilter(expression)) == expression + + +def test_a_filter_is_a_string(): + """Anything reading a filter reads a string, a regular expression included. + + Slicing one yields a plain string rather than a filter, since a fragment of + an expression is not an expression. + """ + scim_filter = ScimFilter('userName eq "bjensen"') + assert isinstance(scim_filter, str) + assert hash(scim_filter) == hash('userName eq "bjensen"') + assert re.match("userName", scim_filter) + assert scim_filter[:8] == "userName" + assert not isinstance(scim_filter[:8], ScimFilter) + + +def test_an_invalid_filter_is_rejected_on_creation(): + with pytest.raises(InvalidFilterException): + ScimFilter("nonsense @") + + +def test_a_filter_can_be_built_from_another_filter(): + original = ScimFilter('userName eq "x"') + assert ScimFilter(original) == original + + +def test_a_filter_exposes_its_parsed_form(): + assert ScimFilter('userName eq "x"').ast == Comparison( + AttrPath("userName"), CompareOperator.eq, "x" + ) + + +def test_a_filter_exposes_the_model_it_is_bound_to(): + assert ScimFilter[User]('userName eq "x"').model is User + assert ScimFilter('userName eq "x"').model is None + + +def test_an_unbound_filter_cannot_match(): + with pytest.raises(TypeError, match="bound filter type"): + ScimFilter('userName eq "x"').match(User(user_name="x")) + + +def test_an_unbound_filter_resolves_to_nothing(): + assert ScimFilter('userName eq "x"').resolve(AttrPath("userName")) is None + + +def test_an_unbound_filter_resolves_no_comparison(): + assert ScimFilter('members co "x"').resolve_comparison(AttrPath("members")) is None + + +def test_an_unbound_filter_validates_to_nothing(): + """Without a model there is nothing to check beyond the syntax.""" + ScimFilter('nonexistent eq "x"')._validate_semantics() + + +def test_a_filter_resolves_its_own_attribute_paths(): + resolved = ScimFilter[User]('emails.type eq "work"').resolve( + AttrPath("emails", "type") + ) + assert resolved.field_name == "emails" + + +def test_a_filter_resolves_a_comparison_to_the_entry_values(): + """A comparison follows the ``value`` convention where a bare path does not. + + A transpiler that resolved the path as written would compare ``members`` + case-insensitively, where :meth:`match` compares ``members.value`` + case-exactly. + """ + scim_filter = ScimFilter[Group]('members co "2819c223"') + assert scim_filter.resolve(AttrPath("members")).sub_field_name is None + + resolved = scim_filter.resolve_comparison(AttrPath("members")) + assert resolved.sub_field_name == "value" + assert resolved.case_exact is True + + +def test_validating_a_whole_filter_walks_every_branch(): + with pytest.raises(InvalidFilterException): + ScimFilter[User]( + "userName pr and not (active gt true) or title pr" + )._validate_semantics() + + +def test_validating_a_value_selection_walks_its_inner_filter(): + with pytest.raises(InvalidFilterException): + ScimFilter[User]('emails[nonexistent eq "x"]')._validate_semantics() + + +def test_validating_a_value_selection_on_a_scalar_list_stops_there(): + """A scalar list has no model to resolve the inner filter against.""" + ScimFilter[User]('schemas[value eq "urn:x"]')._validate_semantics() + + +def test_validating_tolerantly_ignores_unknown_attributes(): + ScimFilter[User]('nonexistent eq "x"')._validate_semantics(strict=False) + + +def test_a_filter_can_be_built_from_a_tree(): + tree = Comparison(AttrPath("userName"), CompareOperator.eq, "x") + assert ScimFilter[User](tree).ast is tree + assert ScimFilter[User](tree) == 'userName eq "x"' + + +def test_a_quoted_value_cannot_be_read_as_syntax(): + untrusted = 'x" or userName pr or userName eq "y' + scim_filter = ScimFilter[User](f"userName eq {ScimFilter.quote(untrusted)}") + assert isinstance(scim_filter.ast, Comparison) + assert scim_filter.ast.value == untrusted + assert not scim_filter.match(User(user_name="bjensen")) + + +def test_a_bound_filter_class_is_reused(): + assert ScimFilter[User] is ScimFilter[User] + assert ScimFilter[User] is not ScimFilter[Group] + + +def test_subscripting_with_a_non_model_falls_back_to_generics(): + + assert ScimFilter[Any] is not None + + +# --- Composition --- + + +def test_expressions_compose_with_the_boolean_operators(): + work = Comparison(AttrPath("type"), CompareOperator.eq, "work") + primary = Present(AttrPath("primary")) + assert str(work & primary) == 'type eq "work" and primary pr' + assert str(work | primary) == 'type eq "work" or primary pr' + assert str(~work) == 'not (type eq "work")' + + +def test_composing_the_same_operator_flattens_the_terms(): + a = Present(AttrPath("a")) + combined = a & a & a + assert combined.op == LogicalOperator.and_ + assert len(combined.terms) == 3 + + +def test_composing_different_operators_keeps_them_nested(): + a, b, c = (Present(AttrPath(name)) for name in "abc") + assert str((a | b) & c) == "(a pr or b pr) and c pr" + assert str(a | (b & c)) == "a pr or b pr and c pr" + + +def test_a_composed_filter_can_be_evaluated(): + tree = Comparison(AttrPath("userName"), CompareOperator.eq, "x") & Present( + AttrPath("title") + ) + assert ScimFilter[User](tree).match(User(user_name="x", title="M")) + + +# --- Helpers --- + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, False), + ("", False), + ("x", True), + ([], False), + (["x"], True), + ({}, False), + ({"a": 1}, True), + (b"", False), + (b"x", True), + (0, True), + (False, True), + (True, True), + ], +) +def test_presence_of_a_value(value, expected): + """``False`` and ``0`` are values, and are therefore present.""" + assert is_present(value) is expected + + +@pytest.mark.parametrize( + ("actual", "expected_value", "operator", "expected"), + [ + (None, None, "eq", True), + (None, "x", "eq", False), + (None, "x", "ne", True), + ("x", None, "ne", True), + (None, None, "ne", False), + (None, "x", "co", False), + (1, "x", "co", False), + ("x", 1, "sw", False), + ("x", "x", "gt", False), + (2, 1, "gt", True), + (1, 2, "lt", True), + ("x", 1, "gt", False), + (1, "x", "lt", False), + ], +) +def test_comparing_two_values(actual, expected_value, operator, expected): + """Incomparable values never match rather than raising.""" + assert compare(actual, expected_value, CompareOperator(operator)) is expected + + +def test_a_visitor_must_implement_every_node_type(): + + class Incomplete(FilterVisitor[str]): + pass + + for expression in [ + 'a eq "x"', + "a pr", + 'not (a eq "x")', + "a pr and b pr", + 'a[b eq "x"]', + ]: + with pytest.raises(NotImplementedError): + Incomplete().visit(parse_filter(expression)) + + +def test_a_visitor_can_transpile_a_filter(): + """The visitor is the extension point a backend query is written with.""" + + class SqlVisitor(FilterVisitor[str]): + def visit_comparison(self, node): + return f"{node.attr_path.attr} = {node.value!r}" + + def visit_present(self, node): + return f"{node.attr_path.attr} IS NOT NULL" + + def visit_not(self, node): + return f"NOT ({self.visit(node.expr)})" + + def visit_logical_expr(self, node): + joiner = " AND " if node.op == LogicalOperator.and_ else " OR " + return joiner.join(self.visit(term) for term in node.terms) + + def visit_value_path(self, node): + return f"EXISTS (SELECT 1 FROM {node.attr_path.attr})" + + assert ( + SqlVisitor().visit(parse_filter('userName eq "x" and title pr')) + == "userName = 'x' AND title IS NOT NULL" + ) + assert SqlVisitor().visit(parse_filter('not (a eq "x")')) == "NOT (a = 'x')" + assert ( + SqlVisitor().visit(parse_filter('emails[type eq "work"]')) + == "EXISTS (SELECT 1 FROM emails)" + ) + assert isinstance(parse_filter("a pr or b pr"), LogicalExpr) + assert isinstance(parse_filter("not (a pr)"), Not) + assert isinstance(parse_filter("a[b pr]"), ValuePath) + + +# --- Remaining corners --- + + +def test_validating_a_negation_tolerantly_walks_its_operand(): + ScimFilter[User]('not (nonexistent eq "x")')._validate_semantics(strict=False) + + +def test_validating_a_value_selection_on_an_unknown_attribute_tolerantly(): + ScimFilter[User]('nonexistent[type eq "x"]')._validate_semantics(strict=False) + + +def test_validating_a_comparison_on_an_unknown_attribute_tolerantly(): + ScimFilter[User]('nonexistent eq "x"')._validate_semantics(strict=False) + + +def test_validating_a_presence_walks_the_attribute(): + with pytest.raises(InvalidFilterException): + ScimFilter[User]("nonexistent pr")._validate_semantics() + + +def test_an_unknown_attribute_makes_the_filter_invalid_not_the_path(): + """§3.12 defines ``invalidPath`` for the ``path`` of a PATCH operation. + + A search filter naming an attribute the model does not declare is an + ``invalidFilter``, which is what a server answers ``400`` with. + """ + with pytest.raises(InvalidFilterException) as exc_info: + ScimFilter[User]('nonexistent eq "x"')._validate_semantics() + assert exc_info.value.scim_type == "invalidFilter" + assert "nonexistent" in str(exc_info.value) + + with pytest.raises(InvalidFilterException) as exc_info: + ScimFilter[User]('emails.nonexistent eq "x"').resolve_comparison( + AttrPath("emails", "nonexistent") + ) + assert exc_info.value.scim_type == "invalidFilter" + + +def test_a_qualified_attribute_within_a_value_selection_is_not_silently_ignored(): + """A URN inside the brackets designates nothing, and used to resolve to nothing. + + The filter kept evaluating, against an attribute it had failed to bind, so + it never matched anything. + """ + expression = f'emails[{User.__schema__}:value eq "bjensen@example.com"]' + scim_filter = ScimFilter[User](expression) + assert not scim_filter.match( + User(user_name="bjensen", emails=[{"value": "bjensen@example.com"}]) + ) + + with pytest.raises(InvalidFilterException) as exc_info: + scim_filter._validate_semantics() + assert exc_info.value.scim_type == "invalidFilter" + assert "cannot qualify" in str(exc_info.value) + + +def test_a_patch_path_keeps_naming_the_path_as_the_invalid_one(): + """The same unknown attribute reached through a PATCH path stays an ``invalidPath``.""" + with pytest.raises(PathNotFoundException) as exc_info: + Path[User]("nonexistent").get(User(user_name="x")) + assert exc_info.value.scim_type == "invalidPath" + + +def test_a_failure_without_a_position_still_reports_a_reason(): + """Not every parse failure carries a column to point at.""" + assert _error_detail(LarkError()) == "invalid syntax" + + +def test_the_model_of_a_sub_attribute_on_a_simple_attribute_is_unknown(): + """``target_model`` has nothing to point at when the head is not complex.""" + resolved = ResolvedAttribute( + model=User, + field_name="user_name", + field_type=str, + is_multivalued=False, + sub_field_name="sub", + sub_field_type=str, + ) + assert resolved.target_model is None + + +def test_an_extension_attribute_read_from_the_extension_itself(): + """Evaluating against an extension instance needs no lookup on a resource.""" + extension = EnterpriseUser(employee_number="1") + assert ScimFilter[EnterpriseUser]('employeeNumber eq "1"').match(extension) + + +def test_a_binary_operand_is_left_alone(): + """A ``binary`` attribute is compared against its base64 form.""" + resolved = resolve_attr_path(User, AttrPath("x509Certificates", "value")) + assert coerce_value(resolved, "aGVsbG8=") == b"hello" + + +def test_a_complex_attribute_operand_is_left_alone(): + """There is nothing to coerce a whole complex attribute into.""" + resolved = resolve_attr_path(User, AttrPath("name")) + assert coerce_value(resolved, "anything") == "anything" + + +def test_validating_a_conjunction_walks_every_term(): + """Every term of a logical expression is checked, not only the first.""" + ScimFilter[User]( + "userName pr and title pr and active eq true" + )._validate_semantics() + + +def test_evaluating_a_value_selection_nested_by_hand_on_a_scalar_list(): + """The grammar forbids nesting, but a hand-built tree may still contain it.""" + nested = ValuePath( + attr_path=AttrPath("schemas"), + val_filter=ValuePath( + attr_path=AttrPath("value"), + val_filter=Present(AttrPath("value")), + ), + ) + user = User( + user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:x"] + ) + assert not ScimFilter[User](nested).match(user) + + +def test_resolution_skips_extensions_that_do_not_match_the_urn(): + """A resource may carry several extensions, only one of which is targeted.""" + + class SuperHero(Extension): + __schema__ = "urn:example:extensions:SuperHero" + superpower: Annotated[str | None, Required.false] = None + + model = User[EnterpriseUser | SuperHero] + resolved = resolve_attr_path( + model, AttrPath("superpower", uri="urn:example:extensions:SuperHero") + ) + assert resolved.model is SuperHero + assert resolved.field_name == "superpower" + + +def test_presence_of_a_complex_attribute_without_any_value(): + """§3.4.2.2 matches "a non-empty node for complex attributes".""" + assert not ScimFilter[User]("name pr").match(User(user_name="x", name=Name())) + assert ScimFilter[User]("name pr").match( + User(user_name="x", name=Name(family_name="Jensen")) + ) + + +def test_presence_of_a_multivalued_attribute_holding_empty_entries(): + assert not ScimFilter[User]("emails pr").match( + User(user_name="x", emails=[User.Emails()]) + ) + assert ScimFilter[User]("emails pr").match( + User(user_name="x", emails=[User.Emails(value="b@example.com")]) + ) + + +def test_presence_of_a_falsy_value_still_matches(): + """``False`` and ``0`` are values, and are thus present.""" + assert ScimFilter[User]("active pr").match(User(user_name="x", active=False)) + + +def test_a_bound_filter_rejects_an_operator_its_attribute_forbids(): + with pytest.raises(InvalidFilterException, match="operator 'gt' cannot be applied"): + ScimFilter[User]("active gt true") + + +def test_a_bound_filter_rejects_a_value_its_attribute_cannot_take(): + with pytest.raises(InvalidFilterException, match="is not valid for attribute"): + ScimFilter[User]('meta.created gt "yesterday"') + + +def test_a_bound_filter_rejects_a_value_selection_on_a_single_valued_attribute(): + with pytest.raises(InvalidFilterException, match="is not multi-valued"): + ScimFilter[User]('userName[value eq "x"]') + + +def test_a_branch_the_evaluation_never_walks_is_still_rejected(): + """The boolean operators short-circuit, so evaluation alone would report it against some resources only.""" + with pytest.raises(InvalidFilterException, match="operator 'gt' cannot be applied"): + ScimFilter[User]('userName eq "bjensen" and active gt true') + + +def test_a_bound_filter_accepts_an_attribute_the_model_does_not_declare(): + """§3.4.2.1 has an endpoint covering several resource types evaluate those to false.""" + scim_filter = ScimFilter[Group]('emails co "@example.com"') + assert not scim_filter.match(Group(display_name="admins")) + + with pytest.raises(InvalidFilterException, match="Field not found: emails"): + scim_filter._validate_semantics() + + +def test_an_unbound_filter_is_only_checked_for_syntax(): + """Nothing resolves attribute names until a filter is bound to a model.""" + assert ScimFilter("active gt true") == "active gt true" + + +def test_a_filter_binds_to_a_union_of_resource_types(): + """An endpoint covering several resource types binds them all, per §3.4.2.1.""" + scim_filter = ScimFilter[User | Group]('userName eq "bjensen"') + assert scim_filter.models == (User, Group) + assert scim_filter.model is None + + +def test_a_union_accepts_the_pipe_syntax(): + assert ScimFilter[User | Group]("members pr").models == (User, Group) + + +def test_a_union_evaluates_against_the_type_of_the_resource(): + """§3.4.2.1 has an attribute a resource type does not declare evaluate to false.""" + scim_filter = ScimFilter[User | Group]('userName eq "bjensen"') + assert scim_filter.match(User(user_name="bjensen")) + assert not scim_filter.match(Group(display_name="admins")) + + members = ScimFilter[User | Group]("members pr") + assert members.match(Group(display_name="admins", members=[{"value": "u1"}])) + assert not members.match(User(user_name="bjensen")) + + +def test_a_union_accepts_an_attribute_a_single_member_declares(): + """A root query names attributes of any type it serves, and of several at once.""" + ScimFilter[User | Group]("members pr")._validate_semantics() + ScimFilter[User | Group]( + 'userName eq "bjensen" or members pr' + )._validate_semantics() + + +def test_a_union_rejects_an_attribute_no_member_declares(): + with pytest.raises(InvalidFilterException, match="Field not found: nonexistent"): + ScimFilter[User | Group]('nonexistent eq "x"')._validate_semantics() + + +def test_a_union_rejects_a_comparison_its_attribute_cannot_take(): + with pytest.raises(InvalidFilterException, match="operator 'gt' cannot be applied"): + ScimFilter[User | Group]("active gt true") + + +def test_resolving_a_union_takes_one_resource_type_at_a_time(): + """A transpiler emits one query per resource type, so it binds each of them in turn.""" + scim_filter = ScimFilter[User | Group]('userName eq "bjensen"') + with pytest.raises(TypeError, match="union of User, Group"): + scim_filter.resolve(AttrPath("userName")) + + with pytest.raises(TypeError, match="union of User, Group"): + scim_filter.resolve_comparison(AttrPath("userName")) + + assert ScimFilter[User](scim_filter).resolve(AttrPath("userName")) is not None + + +def test_a_union_of_something_else_than_models_is_left_alone(): + assert ScimFilter[str | int]("userName pr").models == () + + +def test_a_filter_is_usable_as_a_pydantic_field(): + """A field parses the filter assigned to it, and declares a string in a JSON schema. + + This is how a request message carries its filter: the parsing happens on + the field, so a malformed filter is refused when the message is built. + """ + + class Query(PydanticBaseModel): + scim_filter: ScimFilter[User] + + query = Query(scim_filter='userName eq "bjensen"') + assert isinstance(query.scim_filter, ScimFilter) + assert query.scim_filter.ast == Comparison( + AttrPath("userName"), CompareOperator.eq, "bjensen" + ) + assert query.model_dump() == {"scim_filter": 'userName eq "bjensen"'} + assert Query.model_json_schema()["properties"]["scim_filter"]["type"] == "string" + assert Query(scim_filter=query.scim_filter).scim_filter == query.scim_filter + + with pytest.raises(ValidationError, match="Expected str or ScimFilter, got int"): + Query(scim_filter=42) diff --git a/tests/test_template_strings.py b/tests/test_template_strings.py new file mode 100644 index 00000000..eb28a1a7 --- /dev/null +++ b/tests/test_template_strings.py @@ -0,0 +1,65 @@ +"""t-strings are Python 3.14 syntax, so conftest leaves this module out of older collections.""" + +from datetime import UTC +from datetime import datetime + +import pytest + +from scim2_models import InvalidFilterException +from scim2_models import ScimFilter +from scim2_models import User +from scim2_models.filters import Comparison +from scim2_models.filters import LogicalExpr + + +def test_an_interpolated_string_stays_a_value_whatever_it_contains(): + untrusted = 'x" or userName pr or userName eq "y' + scim_filter = ScimFilter[User](t"userName eq {untrusted}") + assert scim_filter == r'userName eq "x\" or userName pr or userName eq \"y"' + assert isinstance(scim_filter.ast, Comparison) + assert scim_filter.ast.value == untrusted + assert not scim_filter.match(User(user_name="bjensen")) + + +def test_an_interpolated_value_keeps_its_type(): + assert ScimFilter[User](t"active eq {True}") == "active eq true" + assert ScimFilter(t"count eq {18}") == "count eq 18" + assert ScimFilter(t"title eq {None}") == "title eq null" + + +def test_an_interpolated_datetime_renders_as_a_string(): + since = datetime(2011, 5, 13, 4, 42, 34, tzinfo=UTC) + scim_filter = ScimFilter[User](t"meta.lastModified gt {since}") + assert scim_filter == 'meta.lastModified gt "2011-05-13T04:42:34+00:00"' + + +def test_a_conversion_or_a_format_spec_applies_before_quoting(): + assert ScimFilter(t"userName eq {18!r}") == 'userName eq "18"' + assert ScimFilter(t"userName eq {7:03d}") == 'userName eq "007"' + + +def test_interpolations_render_where_they_stand(): + kind, domain = "work", "@example.com" + scim_filter = ScimFilter[User](t"emails[type eq {kind} and value ew {domain}]") + assert scim_filter == 'emails[type eq "work" and value ew "@example.com"]' + user = User( + user_name="bjensen", emails=[{"type": "work", "value": "b@example.com"}] + ) + assert scim_filter.match(user) + + +def test_a_template_is_checked_like_the_string_it_renders(): + with pytest.raises(InvalidFilterException): + ScimFilter(t"userName eq {1} eq") + + +def test_an_interpolated_filter_is_inserted_as_syntax(): + inner = ScimFilter('userName eq "bjensen"') + scim_filter = ScimFilter[User](t"{inner} and title pr") + assert scim_filter == 'userName eq "bjensen" and title pr' + assert isinstance(scim_filter.ast, LogicalExpr) + + +def test_a_converted_filter_is_a_value_again(): + inner = ScimFilter('userName eq "bjensen"') + assert ScimFilter(t"title eq {inner!s}") == r'title eq "userName eq \"bjensen\""' diff --git a/uv.lock b/uv.lock index aa789ea9..8d00734b 100644 --- a/uv.lock +++ b/uv.lock @@ -736,6 +736,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -1514,6 +1523,7 @@ name = "scim2-models" version = "0.7.0" source = { editable = "." } dependencies = [ + { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] @@ -1545,7 +1555,10 @@ doc = [ ] [package.metadata] -requires-dist = [{ name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }] +requires-dist = [ + { name = "lark", specifier = ">=1.3" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.12.0" }, +] [package.metadata.requires-dev] dev = [ From c0dc1c44fc4d11695f56e94cc841b30aaee84e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:40:05 +0200 Subject: [PATCH 02/14] feat: value selections in paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path validates against the PATH rule of RFC7644 §3.5.2, as corrected by errata 7122, instead of a permissive character pattern, so a.b.c is now rejected. get, set and delete honour the selection, letting PatchOp.patch address individual entries of a multi-valued attribute: a selection matching nothing raises noTarget for replace, as §3.5.2.3 mandates, and is a no-op for remove and add, which §3.5.2.2 and errata 8097 leave that way. The filter between the brackets is checked against the model before the resource is read, so an undeclared attribute is reported as invalidFilter whether or not the selected attribute holds values, and the three spellings the errata offers for one selection all answer that filter expressed against an entry. --- doc/changelog.rst | 11 +- scim2_models/filters/__init__.py | 2 + scim2_models/filters/filter.py | 31 ++- scim2_models/path.py | 360 ++++++++++++++++++++++---- tests/test_patch_op_validation.py | 18 ++ tests/test_path.py | 18 +- tests/test_path_value_selection.py | 392 +++++++++++++++++++++++++++++ tests/test_search_request.py | 9 +- tests/test_template_strings.py | 31 +++ 9 files changed, 806 insertions(+), 66 deletions(-) create mode 100644 tests/test_path_value_selection.py diff --git a/doc/changelog.rst b/doc/changelog.rst index aac8f003..7629f77f 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -8,10 +8,12 @@ Added ^^^^^ - Support for the ``filter`` query parameter (:rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`) with :class:`~scim2_models.ScimFilter`. See :doc:`filters`. :issue:`17` +- Support for value selections in PATCH paths, such as ``emails[type eq "work"].value`` + (:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`). - :meth:`ScimFilter.quote ` renders a value as a literal a filter - can carry, and on Python 3.14 :class:`~scim2_models.ScimFilter` takes a t-string: an - interpolated value is quoted so that it cannot be read as syntax, and an interpolated filter - is inserted as it stands. + can carry, and on Python 3.14 :class:`~scim2_models.ScimFilter` and :class:`~scim2_models.Path` + take a t-string: an interpolated value is quoted so that it cannot be read as syntax, and an + interpolated path or filter is inserted as it stands. - :class:`~scim2_models.SearchRequest` and :class:`~scim2_models.ResponseParameters` take the resource type an endpoint serves, as in ``SearchRequest[User]``, which resolves :attr:`~scim2_models.SearchRequest.sort_by`, @@ -30,6 +32,9 @@ Changed unknown entry of :attr:`~scim2_models.ResponseParameters.attributes` is ignored, an order cannot be: a ``sortBy`` left out answers an arbitrary order the client cannot tell from the one it asked for. +- Paths are parsed with the :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` grammar instead of being + checked character by character, so malformed paths such as ``emails[`` or ``userName ==``, + which used to be accepted, are now rejected. Removed ^^^^^^^ diff --git a/scim2_models/filters/__init__.py b/scim2_models/filters/__init__.py index c942f568..e5a0b86a 100644 --- a/scim2_models/filters/__init__.py +++ b/scim2_models/filters/__init__.py @@ -19,6 +19,7 @@ from ..resolution import validate_operator from ..resolution import validate_value_selection from .filter import ScimFilter +from .filter import validate_value_filter from .visitor import Evaluator from .visitor import FilterVisitor from .visitor import compare @@ -49,5 +50,6 @@ "resolve_attr_path", "resolve_comparison_path", "validate_operator", + "validate_value_filter", "validate_value_selection", ] diff --git a/scim2_models/filters/filter.py b/scim2_models/filters/filter.py index bdddd1c1..cdeb1407 100644 --- a/scim2_models/filters/filter.py +++ b/scim2_models/filters/filter.py @@ -109,13 +109,26 @@ def visit_value_path(self, node: ValuePath) -> None: return validate_value_selection(resolved) + validate_value_filter(resolved, node.val_filter, strict=self.strict) - # A list of scalars has no model to resolve the inner filter against. - item_model = resolved.field_type - if isclass(item_model) and issubclass(item_model, BaseModel): - _Validator(item_model, strict=self.strict, urn_prefix=resolved.urn).visit( - node.val_filter - ) + +def validate_value_filter( + resolved: ResolvedAttribute, val_filter: FilterNode, *, strict: bool = True +) -> None: + """Check the filter of a value selection against the attribute it selects from. + + A list of scalars has no model to resolve the inner filter against, so it + is left alone. + + :raises InvalidFilterException: If the filter names an attribute the + selected model does not declare, or compares one in a way it does not + accept. + """ + item_model = resolved.field_type + if not isclass(item_model) or not issubclass(item_model, BaseModel): + return + + _Validator(item_model, strict=strict, urn_prefix=resolved.urn).visit(val_filter) class ScimFilter(_Expression, Generic[ResourceT]): @@ -150,9 +163,9 @@ class ScimFilter(_Expression, Generic[ResourceT]): :attr:`CaseExact.true `. On Python 3.14, a filter can be written as a t-string. Each interpolated - value is quoted, so it stays a value whatever it contains. Another filter - is inserted as it stands, since its syntax was checked when it was - created:: + value is quoted, so it stays a value whatever it contains. A + :class:`~scim2_models.Path` or another filter is inserted as it stands, + since its syntax was checked when it was created:: ScimFilter[User](t"userName eq {name}") diff --git a/scim2_models/path.py b/scim2_models/path.py index 06c66841..a7a9d387 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -1,4 +1,3 @@ -import re from collections.abc import Iterable from collections.abc import Iterator from collections.abc import MutableMapping @@ -9,6 +8,8 @@ from typing import NamedTuple from typing import TypeVar from typing import cast +from typing import get_args +from typing import get_origin from weakref import WeakValueDictionary from pydantic import GetCoreSchemaHandler @@ -18,6 +19,7 @@ from .base import BaseModel from .urn import URN +from .utils import UNION_TYPES from .utils import _find_field_name from .utils import _model_union from .utils import _to_camel @@ -30,13 +32,28 @@ from .annotations import Uniqueness from .resources.resource import Resource +from .exceptions import InvalidFilterException from .exceptions import InvalidPathException +from .exceptions import NoTargetException from .exceptions import PathNotFoundException +from .expressions import AttrPath +from .expressions import Comparison +from .expressions import FilterNode +from .expressions import PathNode +from .expressions import Present +from .expressions import Template +from .expressions import ValuePath +from .expressions import _Expression +from .expressions import _text +from .filters.filter import validate_value_filter +from .filters.visitor import Evaluator +from .grammar import parse_path +from .resolution import attribute_host +from .resolution import resolve_attr_path +from .resolution import validate_value_selection ResourceT = TypeVar("ResourceT", bound="Resource[Any]") -_VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s$]*$') -_ATTRIBUTE_NOTATION_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9._:\-$]*$") _PATH_CACHE: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( WeakValueDictionary() ) @@ -66,6 +83,19 @@ def _designating_schema(path_lower: str, schemas: Iterable[str]) -> str | None: return max(matching, key=len) if matching else None +def _node_attr_path(node: PathNode) -> AttrPath: + """Return the attribute path a parsed path node applies to.""" + if isinstance(node, AttrPath): + return node + return node.attr_path + + +def _accepts_none(model: type[BaseModel], field_name: str) -> bool: + """Whether the annotation of a field allows :data:`None`.""" + annotation = model.model_fields[field_name].annotation + return get_origin(annotation) in UNION_TYPES and type(None) in get_args(annotation) + + def _to_comparable(value: Any) -> Any: """Convert a value to a comparable form (dict for BaseModel).""" return value.model_dump() if isinstance(value, BaseModel) else value @@ -140,9 +170,25 @@ class _Target(NamedTuple): multivalued: bool -class Path(str, Generic[ResourceT]): +class _Selection(NamedTuple): + """The entries a value-selecting path matched, and where they live. + + ``sub_attr`` is the attribute targeted past the brackets, which + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows a PATCH path to name. + """ + + host: Any + field_name: str + matched: list[Any] + sub_attr: str | None + + +class Path(_Expression, Generic[ResourceT]): __scim_models__: tuple[type[BaseModel], ...] = () + _ast: "PathNode | None" = None + """The parsed path, kept once :attr:`ast` has computed it.""" + def __class_getitem__(cls, model: Any) -> type["Path[Any]"]: """Create a Path class bound to a resource type, or to a union of them. @@ -191,15 +237,20 @@ def __get_pydantic_json_schema__( ) -> JsonSchemaValue: return {"type": "string"} - def __new__(cls, path: "str | Path[Any]") -> "Path[Any]": - cls.check_syntax(str(path)) - return super().__new__(cls, path) + def __new__(cls, path: "str | Path[Any] | Template") -> "Path[Any]": + text = _text(path) + cls.check_syntax(text) + return super().__new__(cls, text) @classmethod def check_syntax(cls, path: str) -> None: - """Check if path syntax is valid according to RFC 7644 simplified rules. + """Check that a path conforms to the ``PATH`` rule of :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. - An empty string is valid and represents the resource root. + The grammar is the published ABNF as corrected by + `errata 7122 `_, so a path + is either an attribute path, a value selection optionally followed by a + sub-attribute, or a bare comparison. An empty string is valid and + represents the resource root. :param path: The path to validate :raises InvalidPathException: If the path syntax is invalid @@ -207,34 +258,32 @@ def check_syntax(cls, path: str) -> None: if not path: return - if path[0].isdigit(): - raise InvalidPathException( - path=path, detail="Paths cannot start with a digit" - ) + node = parse_path(path) - if ".." in path: - raise InvalidPathException( - path=path, detail="Paths cannot contain double dots" - ) + uri = _node_attr_path(node).uri + if uri is None: + return - if not _VALID_PATH_PATTERN.match(path): + try: + URN(uri.lower()) + except ValueError as exc: raise InvalidPathException( - path=path, detail="The path contains invalid characters" - ) + path=path, detail=f"The path is not a valid URN: {exc}" + ) from exc - if path.endswith(":"): - raise InvalidPathException( - path=path, detail="Paths cannot end with a colon" - ) + @property + def ast(self) -> "PathNode | None": + """The parsed form of the path, or :data:`None` for the resource root. - if ":" in path: - urn = path.rsplit(":", 1)[0] - try: - URN(urn.lower()) - except ValueError as exc: - raise InvalidPathException( - path=path, detail=f"The path is not a valid URN: {exc}" - ) from exc + >>> from scim2_models import Path + >>> Path("name.familyName").ast + AttrPath(attr='name', sub_attr='familyName', uri=None) + """ + if not self: + return None + if self._ast is None: + self._ast = parse_path(str(self)) + return self._ast def check_attribute_notation(self) -> None: """Check that the path names an attribute instead of selecting values. @@ -247,7 +296,7 @@ def check_attribute_notation(self) -> None: :raises InvalidPathException: If the path is not in attribute notation. """ - if not _ATTRIBUTE_NOTATION_PATTERN.match(self): + if not isinstance(self.ast, AttrPath): raise InvalidPathException( path=str(self), detail=f"{str(self)!r} is not in attribute notation", @@ -260,9 +309,8 @@ def schema(self) -> str | None: For paths like "urn:...:User:userName", returns "urn:...:User". For simple paths like "userName", returns None. """ - if ":" not in self: - return None - return self.rsplit(":", 1)[0] + node = self.ast + return None if node is None else _node_attr_path(node).uri @property def attr(self) -> str: @@ -276,22 +324,55 @@ def attr(self) -> str: a schema is itself a colon-separated name: "urn:...:User" reads as the attribute "User" of the schema "urn:...:2.0". """ - if ":" not in self: - return str(self) - return self.rsplit(":", 1)[1] + node = self.ast + if node is None: + return "" + + rendered = str(node) + uri = _node_attr_path(node).uri + return rendered[len(uri) + 1 :] if uri else rendered @property def parts(self) -> tuple[str, ...]: - """The attribute path segments split by '.'. + """The attribute name, and the sub-attribute name when there is one. + + A value selection is not part of the segments, so the first element is + always the attribute a PATCH operation applies to. For "name.familyName", returns ("name", "familyName"). For "userName", returns ("userName",). + For 'emails[type eq "work"].value', returns ("emails", "value"). For "", returns (). """ - attr = self.attr - if not attr: + node = self.ast + if node is None: return () - return tuple(attr.split(".")) + + attr_path = _node_attr_path(node) + sub_attr = node.sub_attr if isinstance(node, ValuePath) else attr_path.sub_attr + return (attr_path.attr, sub_attr) if sub_attr else (attr_path.attr,) + + @property + def value_filter(self) -> "FilterNode | None": + """The filter selecting the values this path operates on, when it has one. + + The three spellings errata 7122 offers for one selection answer the same + filter, expressed against an entry, so it applies as it stands. An entry + that carries no sub-attribute is addressed through the ``value`` its + entries hold by :rfc:`RFC7643 §2.4 <7643#section-2.4>`. + + >>> from scim2_models import Path + >>> str(Path('emails[type eq "work"].value').value_filter) + 'type eq "work"' + >>> str(Path('emails.type eq "work"').value_filter) + 'type eq "work"' + >>> str(Path('schemas eq "urn:x:y"').value_filter) + 'value eq "urn:x:y"' + >>> Path("emails.value").value_filter is None + True + """ + value_path = self._as_value_path() + return None if value_path is None else value_path.val_filter @property def models(self) -> tuple[type[BaseModel], ...]: @@ -617,8 +698,88 @@ def _create_intermediate(host: BaseModel, field_name: str) -> BaseModel | None: setattr(host, field_name, sub_obj) return sub_obj + def _as_value_path(self) -> ValuePath | None: + """Normalise a value-selecting path into a single representation. + + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` as corrected by errata 7122 + offers three ways to select values of a multi-valued attribute, which + all mean the same thing here:: + + emails[type eq "work"] a value selection + emails.type eq "work" a bare comparison + schemas eq "urn:…" a bare comparison on a scalar list + """ + node = self.ast + if isinstance(node, ValuePath): + return node + + if not isinstance(node, Comparison | Present): + return None + + # A sub-attribute in the comparison becomes the inner filter, so that + # 'emails.type eq "work"' selects like 'emails[type eq "work"]'. Without + # one, the values are scalars, addressed through the "value" convention. + inner_attr = AttrPath(attr=node.attr_path.sub_attr or "value") + head = AttrPath(attr=node.attr_path.attr, uri=node.attr_path.uri) + inner: FilterNode = ( + Present(attr_path=inner_attr) + if isinstance(node, Present) + else Comparison(attr_path=inner_attr, op=node.op, value=node.value) + ) + return ValuePath(attr_path=head, val_filter=inner) + + def _select(self, resource: BaseModel) -> "_Selection | None": + """Resolve a value-selecting path against a resource. + + The filter between the brackets is evaluated strictly, so an attribute + the model does not declare is reported rather than silently matching + nothing. Tolerance belongs to :meth:`get`, :meth:`set` and + :meth:`delete`, which swallow the failure when asked to. + + :returns: The object holding the attribute, the Python field name, and + the matching entries, or :data:`None` if this is not a + value-selecting path. + :raises PathNotFoundException: If the selected attribute is unknown. + :raises InvalidFilterException: If the filter between the brackets + names an attribute the selected model does not declare. + """ + value_path = self._as_value_path() + if value_path is None: + return None + + model = type(resource) + resolved = resolve_attr_path(model, value_path.attr_path, strict=False) + if resolved is None: + raise PathNotFoundException(path=str(self), field=value_path.attr_path.attr) + + # Checked before reading the resource, so that a selection that cannot + # apply is rejected whether or not the attribute happens to be set. + validate_value_selection(resolved) + validate_value_filter(resolved, value_path.val_filter) + + host = attribute_host(resource, resolved) + if host is None: + return _Selection(None, resolved.field_name, [], value_path.sub_attr) + + matched = Evaluator(model, resource).select(value_path) + return _Selection(host, resolved.field_name, matched, value_path.sub_attr) + def _get(self, resource: ResourceT) -> Any: """Get the value at this path from a resource.""" + if (selection := self._select(resource)) is not None: + if selection.sub_attr is None: + return selection.matched or None + values = [ + getattr( + item, + _require_field(type(item), selection.sub_attr, str(self)), + None, + ) + for item in selection.matched + if isinstance(item, BaseModel) + ] + return values or None + if (resolution := self._resolve_instance(resource)) is None: return None @@ -648,16 +809,114 @@ def get(self, resource: ResourceT, *, strict: bool = True) -> Any: :returns: The value at this path, or None if the value is absent. :raises PathNotFoundException: If strict and the path references a non-existent field. :raises InvalidPathException: If strict and the path references an unknown extension. + :raises InvalidFilterException: If strict and a value selection does not + apply to the attribute it selects from. """ try: return self._get(resource) - except InvalidPathException: + except (InvalidPathException, InvalidFilterException): if strict: raise return None + def _set_selected( + self, selection: "_Selection", value: Any, *, is_add: bool = False + ) -> bool: + """Apply a value to every entry matched by a value selection. + + :raises NoTargetException: If a replacement selection matches nothing, + per :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>`. That failure is + defined for ``replace`` only: :rfc:`§3.5.2.1 <7644#section-3.5.2.1>` + says nothing of a selection that matches nothing for ``add``, so + the operation is a no-op instead. `Errata 8097 + `_ asks for value + selections in ``add`` to be clarified at all, implementations + differing on whether they are allowed. + """ + host, field_name, matched, sub_attr = selection + + if not matched: + if is_add: + return False + raise NoTargetException( + detail=f"no value of '{field_name}' matches the path filter" + ) + + if sub_attr is None: + # Without a sub-attribute the matched entries are replaced wholesale. + current = getattr(host, field_name) + replacement = list(current) + item_type = type(host).get_field_root_type(field_name) + new_value = ( + item_type.model_validate(value) + if isinstance(value, dict) + and isclass(item_type) + and issubclass(item_type, BaseModel) + else value + ) + modified = False + for index, item in enumerate(replacement): + if any(item is candidate for candidate in matched): + if not _values_match(item, new_value): + replacement[index] = new_value + modified = True + if modified: + setattr(host, field_name, replacement) + return modified + + modified = False + for item in matched: + item_field = _require_field(type(item), sub_attr, str(self)) + if getattr(item, item_field) != value: + setattr(item, item_field, value) + modified = True + return modified + + def _delete_selected(self, selection: "_Selection") -> bool: + """Remove every entry matched by a value selection. + + A selection that matches nothing leaves the resource untouched and + succeeds: :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` requires + ``noTarget`` only for a missing ``path``, and its removal example + states that "if the user was not a member of this group, no changes + should be made to the resource, and a success response should be + returned". + """ + host, field_name, matched, sub_attr = selection + + if not matched: + return False + + if sub_attr is not None: + modified = False + for item in matched: + item_field = _require_field(type(item), sub_attr, str(self)) + if getattr(item, item_field) is not None: + setattr(item, item_field, None) + modified = True + return modified + + remaining = [ + item + for item in getattr(host, field_name) + if not any(item is candidate for candidate in matched) + ] + # An attribute left without any value is unassigned, which RFC7643 §2.5 + # makes an empty list as much as null. "schemas" is the one annotated + # without None, so it is emptied where the others are unset. Refusing to + # unassign a required attribute belongs to PatchOp, which answers + # "mutability" rather than silently leaving a value behind. + if not remaining and _accepts_none(type(host), field_name): + setattr(host, field_name, None) + else: + setattr(host, field_name, remaining) + return True + def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool: """Set a value at this path on a resource.""" + if (selection := self._select(resource)) is not None: + return self._set_selected(selection, value, is_add=is_add) + if (resolution := self._resolve_instance(resource, create=True)) is None: return False @@ -717,10 +976,14 @@ def set( :param strict: If True, raise exceptions for invalid paths. :returns: True if the value was set/added, False if unchanged. :raises InvalidPathException: If strict and the path does not exist or is invalid. + :raises InvalidFilterException: If strict and a value selection does not + apply to the attribute it selects from. + :raises NoTargetException: If strict, ``is_add`` is false and a value + selection matches nothing. """ try: return self._set(resource, value, is_add=is_add) - except InvalidPathException: + except (InvalidPathException, InvalidFilterException, NoTargetException): if strict: raise return False @@ -757,6 +1020,9 @@ def _set_field_value( def _delete(self, resource: ResourceT, value: Any | None = None) -> bool: """Delete a value at this path from a resource.""" + if (selection := self._select(resource)) is not None: + return self._delete_selected(selection) + if (resolution := self._resolve_instance(resource)) is None: return False @@ -813,10 +1079,12 @@ def delete( :param strict: If True, raise exceptions for invalid paths. :returns: True if a value was deleted, False if unchanged. :raises InvalidPathException: If strict and the path does not exist or is invalid. + :raises InvalidFilterException: If strict and a value selection does not + apply to the attribute it selects from. """ try: return self._delete(resource, value) - except InvalidPathException: + except (InvalidPathException, InvalidFilterException): if strict: raise return False diff --git a/tests/test_patch_op_validation.py b/tests/test_patch_op_validation.py index 424328b8..96f2be50 100644 --- a/tests/test_patch_op_validation.py +++ b/tests/test_patch_op_validation.py @@ -7,6 +7,7 @@ from scim2_models import URN from scim2_models import Extension from scim2_models import Group +from scim2_models import InvalidFilterException from scim2_models import InvalidPathException from scim2_models import InvalidValueException from scim2_models import Mutability @@ -1134,3 +1135,20 @@ def test_an_immutable_extension_attribute_takes_the_value_it_already_has(): patch.patch(user) assert user[ConstrainedExtension].immutable_attr == "original" + + +def test_patch_selection_naming_an_unknown_attribute_fails_the_operation(): + """An attribute the model does not declare must not pass for a no-op success.""" + user = User(user_name="bjensen") + patch = PatchOp[User]( + operations=[ + PatchOperation[User]( + op=PatchOperation.Op.add, + path='emails[nonexistent eq "work"].value', + value="bjensen@example.com", + ) + ] + ) + + with pytest.raises(InvalidFilterException, match="nonexistent"): + patch.patch(user) diff --git a/tests/test_path.py b/tests/test_path.py index 412b4500..d50a4734 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -1210,10 +1210,20 @@ def test_parts_empty_path(): assert path.parts == () -def test_parts_deeply_nested(): - """Deeply nested path splits all parts.""" - path = Path("a.b.c.d") - assert path.parts == ("a", "b", "c", "d") +def test_parts_rejects_more_than_one_sub_attribute(): + """A path cannot nest beyond a single sub-attribute. + + The ``attrPath`` ABNF rule reads ``*1subAttr``, and SCIM defines no nested + complex attributes. + """ + with pytest.raises(InvalidPathException): + Path("a.b.c.d") + + +def test_parts_value_filter_is_not_a_segment(): + """A value selection does not appear in the path segments.""" + path = Path('emails[type eq "work"].value') + assert path.parts == ("emails", "value") # --- Bound path resolution edge cases --- diff --git a/tests/test_path_value_selection.py b/tests/test_path_value_selection.py new file mode 100644 index 00000000..3bb0f1a0 --- /dev/null +++ b/tests/test_path_value_selection.py @@ -0,0 +1,392 @@ +import pytest + +from scim2_models import BaseModel +from scim2_models import EnterpriseUser +from scim2_models import Extension +from scim2_models import Group +from scim2_models import InvalidFilterException +from scim2_models import NoTargetException +from scim2_models import Path +from scim2_models import PathNotFoundException +from scim2_models import ScimFilter +from scim2_models import User + + +@pytest.fixture +def user(): + return User( + user_name="bjensen", + emails=[ + {"type": "work", "value": "work@example.com", "primary": True}, + {"type": "home", "value": "home@example.com", "primary": False}, + {"type": "work", "value": "work2@example.com", "primary": False}, + ], + ) + + +@pytest.fixture +def group(): + return Group( + display_name="Admins", + members=[ + {"value": "2819c223", "display": "Babs"}, + {"value": "902c246f", "display": "Jim"}, + ], + ) + + +# --- Parsed form --- + + +def test_a_value_selecting_path_exposes_its_filter(): + assert str(Path('emails[type eq "work"].value').value_filter) == 'type eq "work"' + + +def test_a_plain_path_has_no_filter(): + assert Path("emails.value").value_filter is None + assert Path("").value_filter is None + + +def test_a_bare_comparison_path_answers_a_filter_over_the_entries(): + """Errata 7122 allows ``attrExp`` as a path, which selects entries by their value. + + The filter is expressed against an entry whichever spelling the path used, + so a consumer applies it without having to rebase it. + """ + assert str(Path('schemas eq "urn:x:y"').value_filter) == 'value eq "urn:x:y"' + assert str(Path('emails.type eq "work"').value_filter) == 'type eq "work"' + + +def test_a_bare_presence_path_answers_a_presence_over_the_entries(): + """A presence test is rebased the same way a comparison is.""" + assert str(Path("title pr").value_filter) == "value pr" + + +def test_the_root_path_has_no_parsed_form(): + assert Path("").ast is None + + +def test_a_value_selection_is_not_part_of_the_segments(): + """``parts[0]`` stays the attribute a PATCH operation applies to.""" + assert Path('emails[type eq "work"].value').parts == ("emails", "value") + assert Path('emails[type eq "work"]').parts == ("emails",) + + +def test_a_value_selecting_path_keeps_its_attribute_portion(): + path = Path( + 'urn:ietf:params:scim:schemas:core:2.0:User:emails[type eq "work"].value' + ) + assert path.schema == "urn:ietf:params:scim:schemas:core:2.0:User" + assert path.attr == 'emails[type eq "work"].value' + + +# --- Reading --- + + +def test_reading_a_sub_attribute_of_the_matching_values(user): + assert Path[User]('emails[type eq "work"].value').get(user) == [ + "work@example.com", + "work2@example.com", + ] + + +def test_reading_the_matching_values_themselves(user): + matched = Path[User]('emails[type eq "home"]').get(user) + assert [email.value for email in matched] == ["home@example.com"] + + +def test_reading_a_selection_that_matches_nothing(user): + assert Path[User]('emails[type eq "other"]').get(user) is None + + +def test_reading_a_sub_attribute_of_a_selection_that_matches_nothing(user): + assert Path[User]('emails[type eq "other"].value').get(user) is None + + +def test_a_quoted_value_cannot_be_read_as_path_syntax(user): + untrusted = 'work" or type pr' + path = Path[User](f"emails[type eq {ScimFilter.quote(untrusted)}].value") + assert str(path.value_filter) == r'type eq "work\" or type pr"' + assert path.get(user) is None + + +def test_reading_a_selection_on_an_unset_attribute(): + assert Path[User]('emails[type eq "work"]').get(User(user_name="x")) is None + + +def test_reading_a_selection_on_an_unknown_attribute_raises(): + with pytest.raises(PathNotFoundException): + Path[User]('nonexistent[type eq "work"]').get(User(user_name="x")) + + +def test_reading_with_a_boolean_selection(user): + matched = Path[User]("emails[primary eq true]").get(user) + assert [email.value for email in matched] == ["work@example.com"] + + +def test_reading_with_a_composed_selection(user): + matched = Path[User]('emails[type eq "work" and primary eq false]').get(user) + assert [email.value for email in matched] == ["work2@example.com"] + + +def test_reading_a_group_member_sub_attribute(group): + assert Path[Group]('members[value eq "2819c223"].display').get(group) == ["Babs"] + + +# --- Writing --- + + +def test_writing_a_sub_attribute_of_the_matching_values(user): + assert Path[User]('emails[type eq "work"].value').set(user, "new@example.com") + assert [email.value for email in user.emails] == [ + "new@example.com", + "home@example.com", + "new@example.com", + ] + + +def test_writing_an_unset_sub_attribute_of_the_matching_values(user): + assert Path[User]('emails[type eq "home"].display').set(user, "Home") + assert [email.display for email in user.emails] == [None, "Home", None] + + +def test_writing_the_same_value_changes_nothing(user): + assert not Path[User]('emails[type eq "home"].value').set(user, "home@example.com") + + +def test_writing_replaces_the_matching_values_wholesale(user): + """§3.5.2.3: all matching record values are replaced.""" + assert Path[User]('emails[type eq "home"]').set( + user, {"type": "other", "value": "other@example.com"} + ) + assert [email.type for email in user.emails] == ["work", "other", "work"] + + +def test_writing_the_same_whole_value_changes_nothing(user): + assert not Path[User]('emails[type eq "home"]').set( + user, {"type": "home", "value": "home@example.com", "primary": False} + ) + + +def test_writing_to_a_selection_that_matches_nothing_has_no_target(user): + """§3.5.2.3 requires a ``noTarget`` failure when no record matched.""" + with pytest.raises(NoTargetException): + Path[User]('emails[type eq "other"].value').set(user, "x") + + +def test_writing_to_a_selection_on_an_unset_attribute_has_no_target(): + with pytest.raises(NoTargetException): + Path[User]('emails[type eq "work"].value').set(User(user_name="x"), "y") + + +def test_writing_to_a_selection_that_matches_nothing_is_silent_when_tolerant(user): + """A tolerant caller gets a plain failure instead of an exception.""" + assert not Path[User]('emails[type eq "other"].value').set(user, "x", strict=False) + + +def test_writing_an_unknown_sub_attribute_raises(user): + with pytest.raises(PathNotFoundException): + Path[User]('emails[type eq "work"].nonexistent').set(user, "x") + + +# --- Removing --- + + +def test_removing_the_matching_values(user): + assert Path[User]('emails[type eq "work"]').delete(user) + assert [email.value for email in user.emails] == ["home@example.com"] + + +def test_removing_a_sub_attribute_of_the_matching_values(user): + assert Path[User]('emails[type eq "work"].value').delete(user) + assert [email.value for email in user.emails] == [ + None, + "home@example.com", + None, + ] + + +def test_removing_an_already_unset_sub_attribute_changes_nothing(user): + assert not Path[User]('emails[type eq "work"].display').delete(user) + + +def test_removing_every_value_unassigns_the_attribute(): + """§3.5.2.2: an attribute left without any value is unassigned.""" + user = User(user_name="x", emails=[{"type": "work", "value": "w@x.com"}]) + assert Path[User]('emails[type eq "work"]').delete(user) + assert user.emails is None + + +def test_removing_a_selection_that_matches_nothing_changes_nothing(user): + """§3.5.2.2 asks for a success and an untouched resource, not a failure. + + Its removal example states that "if the user was not a member of this + group, no changes should be made to the resource, and a success response + should be returned". + """ + before = user.model_dump() + assert not Path[User]('emails[type eq "other"]').delete(user) + assert user.model_dump() == before + + +def test_removing_a_selection_on_an_unset_attribute_changes_nothing(): + assert not Path[User]('emails[type eq "work"]').delete(User(user_name="x")) + + +def test_removing_a_group_member(group): + assert Path[Group]('members[value eq "2819c223"]').delete(group) + assert [member.value for member in group.members] == ["902c246f"] + + +# --- The other two notations --- + + +def test_a_dotted_comparison_selects_like_a_value_path(user): + """``emails.type eq "work"`` means ``emails[type eq "work"]``.""" + assert Path[User]('emails.type eq "work"').delete(user) + assert [email.value for email in user.emails] == ["home@example.com"] + + +def test_a_bare_comparison_selects_a_value_of_a_scalar_list(): + """Errata 7122: the only way to target a value of a non-complex list.""" + user = User( + user_name="x", + schemas=[ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + ) + assert Path[User]( + 'schemas eq "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"' + ).delete(user) + assert user.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] + + +def test_the_value_convention_also_selects_a_value_of_a_scalar_list(): + """Implementations conventionally write ``attr[value eq "…"]``.""" + user = User( + user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:z:w"] + ) + assert Path[User]('schemas[value eq "urn:z:w"]').delete(user) + assert user.schemas == ["urn:ietf:params:scim:schemas:core:2.0:User"] + + +def test_a_bare_presence_selects_every_assigned_value(): + """A bare presence selects every value, and ``schemas`` is emptied rather than unset. + + An attribute left without a value becomes :data:`None`, which the annotation + of ``schemas`` does not allow. + """ + user = User(user_name="x", schemas=["urn:ietf:params:scim:schemas:core:2.0:User"]) + assert Path[User]("schemas pr").delete(user) + assert user.schemas == [] + + +def test_a_selection_on_a_single_valued_extension_attribute_is_invalid(): + """``employeeNumber`` holds one value, so there is nothing to select from.""" + user = User[EnterpriseUser](user_name="x") + user[EnterpriseUser] = EnterpriseUser(employee_number="1") + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber " + 'eq "1"' + ) + with pytest.raises(InvalidFilterException, match="not multi-valued"): + path.delete(user) + + +def test_a_selection_on_an_unset_extension_is_invalid(): + user = User[EnterpriseUser](user_name="x") + path = Path[User[EnterpriseUser]]( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber " + 'eq "1"' + ) + with pytest.raises(InvalidFilterException, match="not multi-valued"): + path.delete(user) + + +def test_a_selection_on_a_single_valued_attribute_is_invalid(): + """§3.5.2 defines a selection over a complex multi-valued attribute only.""" + user = User(user_name="x", name={"family_name": "Jensen"}) + with pytest.raises(InvalidFilterException, match="not multi-valued"): + Path[User]('name[familyName eq "Jensen"]').delete(user) + + +def test_a_selection_on_a_single_valued_attribute_is_silent_when_tolerant(): + user = User(user_name="x", name={"family_name": "Jensen"}) + path = Path[User]('name[familyName eq "Jensen"]') + assert path.get(user, strict=False) is None + assert not path.set(user, "x", strict=False) + assert not path.delete(user, strict=False) + + +class Membership(Extension): + __schema__ = "urn:example:extensions:Membership" + + class Team(BaseModel): + name: str | None = None + role: str | None = None + + teams: list[Team] | None = None + + +def test_a_selection_on_a_multivalued_attribute_of_an_unset_extension(): + """An extension the resource does not carry holds no values to select.""" + user = User[Membership](user_name="x") + path = Path[User[Membership]]( + 'urn:example:extensions:Membership:teams[name eq "core"]' + ) + assert path.get(user) is None + assert not path.delete(user) + + +def test_adding_to_a_selection_that_matches_nothing_changes_nothing(user): + """§3.5.2.1 does not define this case, and errata 8097 leaves it open. + + Microsoft Entra ID emits exactly this operation expecting the entry to be + created, which is not what the published text says, so the operation is a + no-op rather than a failure. + """ + before = user.model_dump() + assert not Path[User]('emails[type eq "other"].value').set( + user, "other@example.com", is_add=True + ) + assert user.model_dump() == before + + +def test_replacing_a_selection_that_matches_nothing_has_no_target(user): + """§3.5.2.3 is the only operation the RFC requires ``noTarget`` for.""" + with pytest.raises(NoTargetException): + Path[User]('emails[type eq "other"].value').set(user, "x") + + +def test_a_selection_comparing_an_invalid_value_is_an_invalid_filter(user): + """``zz`` is not an email address, so the selection cannot be evaluated.""" + path = Path[User]('emails[value eq "zz"]') + with pytest.raises(InvalidFilterException, match="not valid for attribute"): + path.get(user) + + assert path.get(user, strict=False) is None + assert not path.set(user, "x", strict=False) + assert not path.delete(user, strict=False) + + +def test_a_selection_naming_an_unknown_attribute_is_an_invalid_filter(user): + """The filter between the brackets is resolved against the selected model. + + :class:`~scim2_models.ScimFilter` rejects the same expression, so a path + must not let it silently match nothing instead. + """ + path = Path[User]('emails[nonexistent eq "work"].value') + with pytest.raises(InvalidFilterException, match="nonexistent"): + path.get(user) + with pytest.raises(InvalidFilterException, match="nonexistent"): + path.set(user, "x") + with pytest.raises(InvalidFilterException, match="nonexistent"): + path.delete(user) + + +def test_a_selection_naming_an_unknown_attribute_is_silent_when_tolerant(user): + path = Path[User]('emails[nonexistent eq "work"].value') + assert path.get(user, strict=False) is None + assert not path.set(user, "x", strict=False) + assert not path.delete(user, strict=False) diff --git a/tests/test_search_request.py b/tests/test_search_request.py index a7f040a7..c4e29d85 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -129,9 +129,9 @@ def test_search_request_valid_urn_attributes(): def test_search_request_invalid_attributes(): """Test that invalid attribute paths are rejected.""" invalid_cases = [ - (["123invalid"], "Paths cannot start with a digit"), - (["valid", "invalid..path"], "Paths cannot contain double dots"), - (["invalid@character"], "The path contains invalid characters"), + (["123invalid"], "invalid syntax at column 1"), + (["valid", "invalid..path"], "invalid syntax at column 8"), + (["invalid@character"], "invalid syntax at column 8"), ] for attributes, error_match in invalid_cases: @@ -145,8 +145,9 @@ def test_search_request_invalid_excluded_attributes(): "excluded_attributes": ["valid", "123invalid"], # Second one starts with digit } - with pytest.raises(ValidationError, match="Paths cannot start with a digit"): + with pytest.raises(ValidationError) as raised: SearchRequest.model_validate(invalid_data) + assert raised.value.errors()[0]["type"] == "scim_invalidPath" def test_search_request_invalid_sort_by(): diff --git a/tests/test_template_strings.py b/tests/test_template_strings.py index eb28a1a7..dde1835a 100644 --- a/tests/test_template_strings.py +++ b/tests/test_template_strings.py @@ -5,7 +5,10 @@ import pytest +from scim2_models import Group from scim2_models import InvalidFilterException +from scim2_models import InvalidPathException +from scim2_models import Path from scim2_models import ScimFilter from scim2_models import User from scim2_models.filters import Comparison @@ -63,3 +66,31 @@ def test_an_interpolated_filter_is_inserted_as_syntax(): def test_a_converted_filter_is_a_value_again(): inner = ScimFilter('userName eq "bjensen"') assert ScimFilter(t"title eq {inner!s}") == r'title eq "userName eq \"bjensen\""' + + +def test_a_path_quotes_its_interpolations_as_a_filter_does(): + member_id = '2819c223" or value pr' + path = Path[Group](t"members[value eq {member_id}]") + assert path == r'members[value eq "2819c223\" or value pr"]' + assert str(path.value_filter) == r'value eq "2819c223\" or value pr"' + + +def test_reading_through_a_path_written_as_a_template(): + user = User( + user_name="bjensen", emails=[{"type": "work", "value": "b@example.com"}] + ) + kind = "work" + assert Path[User](t"emails[type eq {kind}].value").get(user) == ["b@example.com"] + + +def test_a_path_template_is_checked_like_the_string_it_renders(): + with pytest.raises(InvalidPathException): + Path(t"emails[type eq {1}") + + +def test_an_interpolated_path_is_inserted_as_syntax(): + attribute, name = Path[User]("userName"), "bjensen" + assert ScimFilter[User](t"{attribute} eq {name}") == 'userName eq "bjensen"' + assert ( + Path[User](t"{Path('emails')}[type eq {name}]") == 'emails[type eq "bjensen"]' + ) From e853cdda482b638baa6b52ac47e9d84564b4ba97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:41:21 +0200 Subject: [PATCH 03/14] feat: resolve paths through the attribute resolver Path carried its own resolution next to the one the filter engine brought in, and the two disagreed: attr rendered the whole parsed node, so a value selection left every model-aware property None. Path.resolve is now the single resolution, reading the attribute off the normalised path so that the three spellings of one selection designate the same one, and over the 153 paths iter_paths yields for User, User[EnterpriseUser] and Group, only x509Certificates.value changes, now reported unwrapped as bytes. --- doc/changelog.rst | 2 + scim2_models/path.py | 296 ++++++++++++++--------------- scim2_models/resolution.py | 13 ++ tests/test_filter_semantics.py | 1 + tests/test_path.py | 81 ++++++++ tests/test_path_value_selection.py | 81 +++++++- 6 files changed, 316 insertions(+), 158 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 7629f77f..248c458c 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -22,6 +22,8 @@ Added covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so does :class:`~scim2_models.Path`; a path resolves against the first type declaring it, so ``sortBy`` answers on a root query too. +- :meth:`Path.resolve ` resolves a path to the + :class:`~scim2_models.ResolvedAttribute` it designates. - lark is a new dependency. Changed diff --git a/scim2_models/path.py b/scim2_models/path.py index a7a9d387..75b4e168 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -1,6 +1,7 @@ from collections.abc import Iterable from collections.abc import Iterator from collections.abc import MutableMapping +from dataclasses import replace from inspect import isclass from typing import TYPE_CHECKING from typing import Any @@ -48,6 +49,7 @@ from .filters.filter import validate_value_filter from .filters.visitor import Evaluator from .grammar import parse_path +from .resolution import ResolvedAttribute from .resolution import attribute_host from .resolution import resolve_attr_path from .resolution import validate_value_selection @@ -58,7 +60,7 @@ WeakValueDictionary() ) """The classes subscription has already built, so that two subscriptions of the -same resource type answer the same class. +same resource types answer the same class. The classes are held weakly: one bound to a model built at runtime, as a server serving a schema it discovered does, would otherwise keep that model alive for @@ -210,6 +212,26 @@ def __class_getitem__(cls, model: Any) -> type["Path[Any]"]: _PATH_CACHE[cache_key] = new_class return new_class + def _resolving_model(self) -> "type[BaseModel] | None": + """Return the bound model this path resolves against. + + A path bound to a union resolves against the first type declaring the + attribute it designates, which is unambiguous as long as the types + agree on it. The first type stands in when none declares it, so that + errors name a model. + """ + if not self.__scim_models__: + return None + + designated = self._designated_attr_path() + if designated is None or len(self.__scim_models__) == 1: + return self.__scim_models__[0] + + for model in self.__scim_models__: + if resolve_attr_path(model, designated, strict=False) is not None: + return model + return self.__scim_models__[0] + @classmethod def __get_pydantic_core_schema__( cls, @@ -312,25 +334,43 @@ def schema(self) -> str | None: node = self.ast return None if node is None else _node_attr_path(node).uri + def _designated_attr_path(self) -> AttrPath | None: + """Return the attribute path this path designates, selection excluded. + + A value selection carries its sub-attribute past the brackets, so + ``emails[type eq "work"].value`` designates ``emails.value``. The + resource root designates no attribute at all. + + The path is normalised first, so the three spellings errata 7122 offers + for one selection designate the same attribute: the sub-attribute of + ``emails.type eq "work"`` belongs to the filter, not to the attribute + the path operates on. + """ + if self.ast is None: + return None + + node = self._as_value_path() or self.ast + attr_path = _node_attr_path(node) + sub_attr = node.sub_attr if isinstance(node, ValuePath) else attr_path.sub_attr + return AttrPath(attr_path.attr, sub_attr, attr_path.uri) + @property def attr(self) -> str: - """The attribute portion of the path. + """The attribute portion of the path, selection and filter excluded. For paths like "urn:...:User:userName", returns "userName". For simple paths like "userName", returns "userName". + For 'emails[type eq "work"].value', returns "emails.value". For the empty path, which designates the resource itself, returns "". Nothing tells a schema-only path from a qualified one, since the URN of a schema is itself a colon-separated name: "urn:...:User" reads as the attribute "User" of the schema "urn:...:2.0". """ - node = self.ast - if node is None: + designated = self._designated_attr_path() + if designated is None: return "" - - rendered = str(node) - uri = _node_attr_path(node).uri - return rendered[len(uri) + 1 :] if uri else rendered + return str(replace(designated, uri=None)) @property def parts(self) -> tuple[str, ...]: @@ -344,13 +384,12 @@ def parts(self) -> tuple[str, ...]: For 'emails[type eq "work"].value', returns ("emails", "value"). For "", returns (). """ - node = self.ast - if node is None: + designated = self._designated_attr_path() + if designated is None: return () - - attr_path = _node_attr_path(node) - sub_attr = node.sub_attr if isinstance(node, ValuePath) else attr_path.sub_attr - return (attr_path.attr, sub_attr) if sub_attr else (attr_path.attr,) + if designated.sub_attr: + return (designated.attr, designated.sub_attr) + return (designated.attr,) @property def value_filter(self) -> "FilterNode | None": @@ -374,6 +413,61 @@ def value_filter(self) -> "FilterNode | None": value_path = self._as_value_path() return None if value_path is None else value_path.val_filter + def _designated_model(self) -> type[BaseModel] | None: + """Return the model this path designates when it names no attribute. + + The resource root and a bare schema URN both designate a model rather + than one of its attributes, and neither can go through attribute + resolution: the root names nothing, and nothing tells a schema URN from + a qualified path syntactically, so it is recognised by comparing the + whole path to the schemas the bound model knows. + """ + from .resources.resource import Extension + from .resources.resource import Resource + + if not self.__scim_models__: + return None + + if self.ast is None: + return self.__scim_models__[0] + + path = self.lower() + for model in self.__scim_models__: + if not (isclass(model) and issubclass(model, Resource | Extension)): + continue + + if model.__schema__ and path == model.__schema__.lower(): + return model + + if not issubclass(model, Resource): + continue + + for schema, extension_model in model.get_extension_models().items(): + if path == schema.lower(): + return extension_model + + return None + + def resolve(self) -> "ResolvedAttribute | None": + """Bind this path to the attribute it designates on the bound model. + + This is the single resolution the model-aware properties are built on. + + :returns: The resolved attribute, or :data:`None` when the path is + unbound, designates a model rather than an attribute, or names an + attribute the model does not declare. + """ + model = self._resolving_model() + if model is None or self._designated_model() is not None: + return None + + # A path that designates no attribute has already returned above, + # since _designated_model answers for the resource root. + designated = self._designated_attr_path() + assert designated is not None + + return resolve_attr_path(model, designated, strict=False) + @property def models(self) -> tuple[type[BaseModel], ...]: """The resource types the path is bound to, none for an unbound path. @@ -386,7 +480,7 @@ def models(self) -> tuple[type[BaseModel], ...]: @property def model(self) -> type[BaseModel] | None: - """The target model type for this path. + """The model holding the attribute this path designates. Requires the Path to be bound to a model type via ``Path[Model]``. Returns None if the path is unbound or invalid. @@ -394,67 +488,66 @@ def model(self) -> type[BaseModel] | None: For "name.familyName" on Path[User], returns Name. For "userName" on Path[User], returns User. """ - if (result := self._resolve_model()) is None: - return None - return result[1] + if (designated := self._designated_model()) is not None: + return designated + + resolved = self.resolve() + return resolved.target_model if resolved is not None else None @property def field_name(self) -> str | None: """The Python attribute name (snake_case) for this path. Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound or invalid. + Returns None if the path is unbound, invalid, or designates a model + rather than one of its attributes. For "name.familyName" on Path[User], returns "family_name". For "userName" on Path[User], returns "user_name". """ - if (result := self._resolve_model()) is None: - return None - return result[2] + resolved = self.resolve() + return resolved.target_field_name if resolved is not None else None @property def field_type(self) -> type | None: """The Python type of the field this path points to. - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound, invalid, or points to a schema-only path. + Annotated types are unwrapped, so a ``binary`` attribute declared as + ``Base64Bytes`` reports :class:`bytes`. For "userName" on Path[User], returns str. For "name" on Path[User], returns Name. For "emails" on Path[User], returns Email. """ - if self.model is None or self.field_name is None: - return None - return self.model.get_field_root_type(self.field_name) + resolved = self.resolve() + return resolved.target_type if resolved is not None else None @property def is_multivalued(self) -> bool | None: """Whether this path points to a multi-valued attribute. - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound, invalid, or points to a schema-only path. - For "emails" on Path[User], returns True. - For "userName" on Path[User], returns False. + For "emails.value" on Path[User], returns False, as the path + designates one value per entry. """ - if self.model is None or self.field_name is None: - return None - return self.model.get_field_multiplicity(self.field_name) + resolved = self.resolve() + return resolved.target_is_multivalued if resolved is not None else None def get_annotation(self, annotation_type: type) -> Any: """Get annotation value for this path's field. - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound, invalid, or points to a schema-only path. - :param annotation_type: The annotation class (e.g., Required, Mutability). - :returns: The annotation value or None. + :returns: The annotation value, or None when the path designates no + attribute. For "userName" on Path[User] with Required, returns Required.true. """ - if self.model is None or self.field_name is None: + resolved = self.resolve() + if resolved is None or resolved.target_model is None: return None - return self.model.get_field_annotation(self.field_name, annotation_type) + return resolved.target_model.get_field_annotation( + resolved.target_field_name, annotation_type + ) @property def urn(self) -> str | None: @@ -466,131 +559,24 @@ def urn(self) -> str | None: For "userName" on Path[User], returns "urn:ietf:params:scim:schemas:core:2.0:User:userName". """ - from .resources.resource import Resource - - if (result := self._resolve_model()) is None: - return None - - bound = result[0] - schema = self.schema - if not schema and issubclass(bound, Resource): - schema = bound.__schema__ - - if not self.attr: - return schema if schema else None - return f"{schema}:{self.attr}" if schema else self.attr - - def _designated_model( - self, model: type[BaseModel] - ) -> "tuple[type[BaseModel], str] | None": - """Resolve the model a URN designates, and the path expressed in it. - - A qualified path is expressed either in the schema of the bound model or - in that of one of its extensions, and names an attribute of whichever - one it designates. - """ - from .resources.resource import Extension - from .resources.resource import Resource - - attr_path = self.attr - - if ":" in self and isclass(model) and issubclass(model, Resource | Extension): - path_lower = str(self).lower() - - extension_models = ( - model.get_extension_models() if issubclass(model, Resource) else {} - ) - schema = _designating_schema( - path_lower, [model.__schema__ or "", *extension_models] - ) - - if schema is None: - return None - - model = extension_models.get(schema, model) - if path_lower == schema.lower(): - return model, "" - attr_path = str(self)[len(schema) :].lstrip(":") + if (designated := self._designated_model()) is not None: + return getattr(designated, "__schema__", None) or None - return model, attr_path + resolved = self.resolve() + return resolved.urn if resolved is not None else None def _resolve_head(self) -> "tuple[type[BaseModel], str] | None": """Resolve the attribute the path applies to, and the model declaring it. A constraint on a complex attribute governs everything written under it, - so it is the attribute a path applies to that answers for the operation + so it is the attribute a path applies to that answers for an operation rather than the sub-attribute it targets: ``meta`` is read-only where the ``meta.version`` it holds is not. """ - for model in self.__scim_models__: - if (head := self._resolve_head_against(model)) is not None: - return head - return None - - def _resolve_head_against( - self, model: type[BaseModel] - ) -> "tuple[type[BaseModel], str] | None": - """Resolve the attribute the path applies to, against one bound model.""" - if (designated := self._designated_model(model)) is None: - return None - - model, attr_path = designated - if not attr_path: - return None - - head = attr_path.split(".", 1)[0] - if (field_name := _find_field_name(model, head)) is None: - return None - return model, field_name - - def _resolve_model( - self, - ) -> tuple[type[BaseModel], type[BaseModel], str | None] | None: - """Resolve the path against the bound models, the first declaring it winning. - - Answers the bound model the path resolves against, the model holding the - attribute and the name of its field, which a schema-only path has none of. - """ - for model in self.__scim_models__: - if (result := self._resolve_model_against(model)) is not None: - return (model, *result) - return None - - def _resolve_model_against( - self, model: type[BaseModel] - ) -> tuple[type[BaseModel], str | None] | None: - """Resolve the path against one bound model.""" - if (designated := self._designated_model(model)) is None: - return None - - model, attr_path = designated - - if not attr_path: - return model, None - - if "." in attr_path: - parts = attr_path.split(".") - current_model = model - - for part in parts[:-1]: - if (field_name := _find_field_name(current_model, part)) is None: - return None - field_type = current_model.get_field_root_type(field_name) - if ( - field_type is None - or not isclass(field_type) - or not issubclass(field_type, BaseModel) - ): - return None - current_model = field_type - - if (field_name := _find_field_name(current_model, parts[-1])) is None: - return None - return current_model, field_name - - if (field_name := _find_field_name(model, attr_path)) is None: + resolved = self.resolve() + if resolved is None: return None - return model, field_name + return resolved.model, resolved.field_name def _resolve_instance( self, resource: BaseModel, *, create: bool = False diff --git a/scim2_models/resolution.py b/scim2_models/resolution.py index 793ff44d..7aaa9ef3 100644 --- a/scim2_models/resolution.py +++ b/scim2_models/resolution.py @@ -93,6 +93,19 @@ def target_field_name(self) -> str: """The Python name of the attribute actually compared.""" return self.sub_field_name or self.field_name + @property + def target_is_multivalued(self) -> bool | None: + """Whether the attribute actually compared holds several values. + + This differs from :attr:`is_multivalued`, which describes the head + attribute: ``emails.value`` designates one value per entry, while + ``emails`` holds several. + """ + model = self.target_model + if model is None: + return None + return model.get_field_multiplicity(self.target_field_name) + def nested_in(self, urn: str) -> "ResolvedAttribute": """Return the same attribute, qualified by the URN it was resolved under. diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py index 2bdf54d8..ec408bb9 100644 --- a/tests/test_filter_semantics.py +++ b/tests/test_filter_semantics.py @@ -960,6 +960,7 @@ def test_the_model_of_a_sub_attribute_on_a_simple_attribute_is_unknown(): sub_field_type=str, ) assert resolved.target_model is None + assert resolved.target_is_multivalued is None def test_an_extension_attribute_read_from_the_extension_itself(): diff --git a/tests/test_path.py b/tests/test_path.py index d50a4734..9a7fa2e8 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -20,6 +20,7 @@ from scim2_models import PatchOp from scim2_models import PathNotFoundException from scim2_models import Required +from scim2_models import ResolvedAttribute from scim2_models import Resource from scim2_models import Returned from scim2_models import Uniqueness @@ -1837,3 +1838,83 @@ def test_an_extension_urn_carries_an_attribute_behind_a_colon(): assert bound(f"{enterprise}:employeeNumber").get(user) == "701984" with pytest.raises(InvalidPathException): bound(f"{enterprise}EmployeeNumber").get(user) + + +def test_resolving_a_path_binds_it_to_the_attribute_it_designates(): + resolved = Path[User]("name.familyName").resolve() + assert isinstance(resolved, ResolvedAttribute) + assert resolved.model is User + assert resolved.field_name == "name" + assert resolved.sub_field_name == "family_name" + assert resolved.target_model is Name + assert resolved.target_field_name == "family_name" + + +def test_a_path_designating_a_model_rather_than_an_attribute_resolves_to_nothing(): + """The resource root and a bare schema URN both name a model.""" + assert Path[User]("").resolve() is None + assert Path[User]("").model is User + assert Path[User]("").field_name is None + + extension_urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + schema_only = Path[User[EnterpriseUser]](extension_urn) + assert schema_only.resolve() is None + assert schema_only.model is EnterpriseUser + assert schema_only.field_name is None + + +def test_an_unbound_or_unknown_path_resolves_to_nothing(): + assert Path("userName").resolve() is None + assert Path[User]("nonexistent").resolve() is None + assert Path[User]("nonexistent").model is None + + +def test_a_path_bound_to_a_complex_attribute_resolves_its_sub_attributes(): + """A complex attribute is a model too, though it carries no schema.""" + path = Path[Name]("familyName") + assert path.model is Name + assert path.field_name == "family_name" + assert path.field_type is str + assert path.get_annotation(Mutability) == Mutability.read_write + + +def test_the_type_of_an_annotated_attribute_is_unwrapped(): + """A ``binary`` attribute declared as Base64Bytes reports bytes.""" + assert Path[User]("x509Certificates.value").field_type is bytes + + +def test_a_sub_attribute_of_a_multivalued_attribute_is_not_multivalued(): + """``emails`` holds several values, ``emails.value`` designates one per entry.""" + assert Path[User]("emails").is_multivalued is True + assert Path[User]("emails.value").is_multivalued is False + + +@pytest.mark.parametrize( + "model", + [ + pytest.param(User, id="User"), + pytest.param(User[EnterpriseUser], id="User with an extension"), + pytest.param(Group, id="Group"), + ], +) +def test_every_enumerated_path_agrees_with_its_resolution(model): + """The model-aware properties agree with ``resolve`` over a whole model. + + They all read that single resolution, so this covers the full attribute + surface of a resource and its extensions at once, and breaks if a second + mechanism or a diverging convention creeps back in. + """ + paths = list(Path[model].iter_paths()) + assert paths + + for path in paths: + if path.resolve() is None: + # A bare schema URN names a model rather than one of its attributes. + assert path.model is not None + assert path.field_name is None + continue + + assert path.field_name in path.model.model_fields + assert path.field_type is not None + assert path.is_multivalued is not None + assert path.urn.endswith(path.attr) diff --git a/tests/test_path_value_selection.py b/tests/test_path_value_selection.py index 3bb0f1a0..9e880d8a 100644 --- a/tests/test_path_value_selection.py +++ b/tests/test_path_value_selection.py @@ -1,10 +1,13 @@ import pytest from scim2_models import BaseModel +from scim2_models import CaseExact +from scim2_models import Email from scim2_models import EnterpriseUser from scim2_models import Extension from scim2_models import Group from scim2_models import InvalidFilterException +from scim2_models import Mutability from scim2_models import NoTargetException from scim2_models import Path from scim2_models import PathNotFoundException @@ -72,12 +75,15 @@ def test_a_value_selection_is_not_part_of_the_segments(): assert Path('emails[type eq "work"]').parts == ("emails",) -def test_a_value_selecting_path_keeps_its_attribute_portion(): - path = Path( +def test_a_value_selecting_path_designates_an_attribute(user): + """The selection is not part of the attribute the path designates.""" + path = Path[User]( 'urn:ietf:params:scim:schemas:core:2.0:User:emails[type eq "work"].value' ) assert path.schema == "urn:ietf:params:scim:schemas:core:2.0:User" - assert path.attr == 'emails[type eq "work"].value' + assert path.attr == "emails.value" + assert path.parts == ("emails", "value") + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" # --- Reading --- @@ -390,3 +396,72 @@ def test_a_selection_naming_an_unknown_attribute_is_silent_when_tolerant(user): assert path.get(user, strict=False) is None assert not path.set(user, "x", strict=False) assert not path.delete(user, strict=False) + + +@pytest.mark.parametrize( + ("path", "field_name"), + [ + pytest.param('emails[type eq "work"].value', "value", id="value selection"), + pytest.param('emails.type eq "work"', "emails", id="bare comparison"), + pytest.param("emails pr", "emails", id="bare presence"), + ], +) +def test_every_notation_resolves_against_the_model(path, field_name): + """The three notations designate an attribute, so they all resolve. + + :attr:`~scim2_models.Path.model` and its siblings used to answer ``None`` + for anything but a plain attribute path, since they read the rendered + expression instead of the attribute it designates. + """ + resolved = Path[User](path) + assert resolved.field_name == field_name + assert resolved.model is not None + assert resolved.field_type is not None + assert resolved.get_annotation(Mutability) is not None + + +def test_the_two_spellings_of_one_selection_designate_the_same_attribute(user): + """``emails.type eq "work"`` selects the same values as ``emails[type eq "work"]``. + + The sub-attribute of the bare form belongs to the filter rather than to the + attribute the path operates on, so the introspection answers on ``emails`` + for both, which is what reading through either already returned. + """ + bracketed = Path[User]('emails[type eq "work"]') + bare = Path[User]('emails.type eq "work"') + + assert bare.get(user) == bracketed.get(user) + assert bare.parts == bracketed.parts == ("emails",) + assert bare.attr == bracketed.attr == "emails" + assert bare.model is bracketed.model is User + assert bare.field_name == bracketed.field_name == "emails" + assert bare.field_type is bracketed.field_type is Email + assert bare.is_multivalued is bracketed.is_multivalued is True + assert bare.urn == bracketed.urn == f"{User.__schema__}:emails" + + +def test_the_annotations_of_a_bare_comparison_come_from_the_attribute_it_selects(): + """``members`` is not case-exact where the ``members.value`` it filters on is. + + Reading the annotations off the filtered sub-attribute would describe an + attribute the path does not operate on. + """ + bracketed = Path[Group]('members[value eq "u1"]') + bare = Path[Group]('members.value eq "u1"') + + assert bare.get_annotation(CaseExact) is bracketed.get_annotation(CaseExact) + assert bracketed.get_annotation(CaseExact) is CaseExact.false + + +def test_a_selection_designates_the_sub_attribute_it_targets(): + path = Path[User]('emails[type eq "work"].value') + assert path.model is Email + assert path.field_name == "value" + assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + + +def test_a_selection_without_a_sub_attribute_designates_the_attribute_itself(): + path = Path[User]('emails[type eq "work"]') + assert path.model is User + assert path.field_name == "emails" + assert path.is_multivalued is True From 69f8820d4969c887327e0f3d4cc58df9f38fe434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:43:34 +0200 Subject: [PATCH 04/14] refactor: share the model binding of Path and ScimFilter Both are strings whose attribute names only mean something against a model, and both carried their own subscription, class cache and pydantic plumbing. They now derive from _BoundToModels, which keeps the generated class name and the error messages each of them used to build by hand, and turns whatever either gets wrong into a pydantic error, where only a path used to name the field it was read from and carry its scimType. --- scim2_models/binding.py | 97 ++++++++++++++++++++++++++++++++++ scim2_models/filters/filter.py | 77 +++------------------------ scim2_models/path.py | 71 +------------------------ 3 files changed, 107 insertions(+), 138 deletions(-) create mode 100644 scim2_models/binding.py diff --git a/scim2_models/binding.py b/scim2_models/binding.py new file mode 100644 index 00000000..61fad598 --- /dev/null +++ b/scim2_models/binding.py @@ -0,0 +1,97 @@ +from collections.abc import MutableMapping +from typing import TYPE_CHECKING +from typing import Any +from weakref import WeakValueDictionary + +from pydantic import GetCoreSchemaHandler +from pydantic import GetJsonSchemaHandler +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + +from .exceptions import SCIMException +from .utils import _model_union + +if TYPE_CHECKING: + from .base import BaseModel + +_BOUND_CLASSES: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( + WeakValueDictionary() +) +"""The classes subscription has already built, so that two subscriptions of the +same resource types answer the same class. + +The classes are held weakly: one bound to a model built at runtime, as a server +serving a schema it discovered does, would otherwise keep that model alive for +as long as the process runs.""" + + +class _BoundToModels: + """A string whose attribute names are resolved against resource types. + + :class:`~scim2_models.Path` and :class:`~scim2_models.ScimFilter` both name + attributes that only mean something against a model, and both are + subscripted with the resource types an endpoint serves. Subscription, the + cache of classes it fills and the pydantic plumbing are the same for either. + + The :attr:`~scim2_models.Path.models` each of them answers stays declared + where it is documented, since autodoc leaves an inherited member out. + """ + + __scim_models__: "tuple[type[BaseModel], ...]" = () + + @classmethod + def _unbound_name(cls) -> str: + """Return the name of the type, without the models bound to it.""" + return cls.__name__.split("[", 1)[0] + + def __class_getitem__(cls, model: Any) -> type: + """Create a class bound to a resource type, or to a union of them. + + A union is what an endpoint covering several resource types binds, such + as the server root of :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`. + Anything that is not a resource type, a type variable in particular, is + left to the generic machinery. + """ + models = _model_union(model) + if models is None: + return super().__class_getitem__(model) # type: ignore[misc,no-any-return] + + cache_key = (cls, models) + if cache_key not in _BOUND_CLASSES: + names = ", ".join(each.__name__ for each in models) + _BOUND_CLASSES[cache_key] = type( + f"{cls._unbound_name()}[{names}]", (cls,), {"__scim_models__": models} + ) + return _BOUND_CLASSES[cache_key] + + @classmethod + def __get_pydantic_core_schema__( + cls, + _source_type: type[Any], + _handler: GetCoreSchemaHandler, + ) -> core_schema.CoreSchema: + def validate(value: Any) -> Any: + if not isinstance(value, str): + raise ValueError( + f"Expected str or {cls._unbound_name()}, got {type(value).__name__}" + ) + # Whatever the value gets wrong is reported through the pydantic + # error, so the failure names the field it was read from and still + # carries its scimType. + try: + return cls(value) # type: ignore[call-arg] + except SCIMException as exc: + raise exc.as_pydantic_error() from exc + + return core_schema.no_info_plain_validator_function( + validate, + serialization=core_schema.plain_serializer_function_ser_schema(str), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, + _core_schema: core_schema.CoreSchema, + _handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + return {"type": "string"} diff --git a/scim2_models/filters/filter.py b/scim2_models/filters/filter.py index cdeb1407..a84c00ce 100644 --- a/scim2_models/filters/filter.py +++ b/scim2_models/filters/filter.py @@ -1,17 +1,11 @@ -from collections.abc import MutableMapping from inspect import isclass from typing import TYPE_CHECKING from typing import Any from typing import Generic from typing import TypeVar -from weakref import WeakValueDictionary - -from pydantic import GetCoreSchemaHandler -from pydantic import GetJsonSchemaHandler -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import core_schema from ..base import BaseModel +from ..binding import _BoundToModels from ..expressions import AttrPath from ..expressions import Comparison from ..expressions import FilterNode @@ -29,7 +23,6 @@ from ..resolution import resolve_filter_path from ..resolution import validate_operator from ..resolution import validate_value_selection -from ..utils import _model_union from .visitor import Evaluator from .visitor import FilterVisitor @@ -38,16 +31,6 @@ ResourceT = TypeVar("ResourceT", bound="Resource[Any]") -_FILTER_CACHE: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( - WeakValueDictionary() -) -"""The classes subscription has already built, so that two subscriptions of the -same resource types answer the same class. - -The classes are held weakly, as a path binding holds its own: one bound to a -model built at runtime would otherwise keep that model alive for as long as the -process runs.""" - class _Validator(FilterVisitor[None]): """Check every expression of a filter against the model it applies to.""" @@ -131,7 +114,7 @@ def validate_value_filter( _Validator(item_model, strict=strict, urn_prefix=resolved.urn).visit(val_filter) -class ScimFilter(_Expression, Generic[ResourceT]): +class ScimFilter(_BoundToModels, _Expression, Generic[ResourceT]): """A SCIM filter, as defined at :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`. A filter *is* the string it was built from, so it can be passed around, @@ -172,27 +155,16 @@ class ScimFilter(_Expression, Generic[ResourceT]): :meth:`quote` quotes a value dropped into a plain string the same way. """ - __scim_models__: tuple[type[BaseModel], ...] = () - _ast: FilterNode - def __class_getitem__(cls, model: type[ResourceT]) -> type["ScimFilter[ResourceT]"]: - """Create a filter class bound to a resource type, or to a union of them. + @property + def models(self) -> tuple[type[BaseModel], ...]: + """The resource types this filter is bound to, empty when it is unbound. - A union is what an endpoint covering several resource types binds, such - as the server root of :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`. + It holds several models when the filter is bound to a union, which is + what an endpoint covering several resource types does. """ - models = _model_union(model) - if models is None: - return super().__class_getitem__(model) # type: ignore[misc,no-any-return] - - cache_key = (cls, models) - if cache_key not in _FILTER_CACHE: - name = ", ".join(each.__name__ for each in models) - _FILTER_CACHE[cache_key] = type( - f"ScimFilter[{name}]", (cls,), {"__scim_models__": models} - ) - return _FILTER_CACHE[cache_key] + return self.__scim_models__ def __new__( cls, expression: "str | ScimFilter[Any] | FilterNode | Template" @@ -214,30 +186,6 @@ def __new__( filter_._validate_semantics(strict=False) return filter_ - @classmethod - def __get_pydantic_core_schema__( - cls, - _source_type: type[Any], - _handler: GetCoreSchemaHandler, - ) -> core_schema.CoreSchema: - def validate(value: Any) -> "ScimFilter[Any]": - if isinstance(value, str): - return cls(str(value)) - raise ValueError(f"Expected str or ScimFilter, got {type(value).__name__}") - - return core_schema.no_info_plain_validator_function( - validate, - serialization=core_schema.plain_serializer_function_ser_schema(str), - ) - - @classmethod - def __get_pydantic_json_schema__( - cls, - _core_schema: core_schema.CoreSchema, - _handler: GetJsonSchemaHandler, - ) -> JsonSchemaValue: - return {"type": "string"} - @property def ast(self) -> FilterNode: """The parsed form of the filter. @@ -287,15 +235,6 @@ def _single_model(self, method: str) -> "type[BaseModel] | None": ) return self.model - @property - def models(self) -> tuple[type[BaseModel], ...]: - """The resource types this filter is bound to, empty when it is unbound. - - It holds several models when the filter is bound to a union, which is - what an endpoint covering several resource types does. - """ - return self.__scim_models__ - @property def model(self) -> type[BaseModel] | None: """The model this filter is bound to, or :data:`None` when it is unbound. diff --git a/scim2_models/path.py b/scim2_models/path.py index 75b4e168..fd9e1336 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -1,6 +1,5 @@ from collections.abc import Iterable from collections.abc import Iterator -from collections.abc import MutableMapping from dataclasses import replace from inspect import isclass from typing import TYPE_CHECKING @@ -11,18 +10,12 @@ from typing import cast from typing import get_args from typing import get_origin -from weakref import WeakValueDictionary - -from pydantic import GetCoreSchemaHandler -from pydantic import GetJsonSchemaHandler -from pydantic.json_schema import JsonSchemaValue -from pydantic_core import core_schema from .base import BaseModel +from .binding import _BoundToModels from .urn import URN from .utils import UNION_TYPES from .utils import _find_field_name -from .utils import _model_union from .utils import _to_camel if TYPE_CHECKING: @@ -56,16 +49,6 @@ ResourceT = TypeVar("ResourceT", bound="Resource[Any]") -_PATH_CACHE: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( - WeakValueDictionary() -) -"""The classes subscription has already built, so that two subscriptions of the -same resource types answer the same class. - -The classes are held weakly: one bound to a model built at runtime, as a server -serving a schema it discovered does, would otherwise keep that model alive for -as long as the process runs.""" - def _is_in_schema(path_lower: str, schema: str) -> bool: schema_lower = schema.lower() @@ -185,33 +168,10 @@ class _Selection(NamedTuple): sub_attr: str | None -class Path(_Expression, Generic[ResourceT]): - __scim_models__: tuple[type[BaseModel], ...] = () - +class Path(_BoundToModels, _Expression, Generic[ResourceT]): _ast: "PathNode | None" = None """The parsed path, kept once :attr:`ast` has computed it.""" - def __class_getitem__(cls, model: Any) -> type["Path[Any]"]: - """Create a Path class bound to a resource type, or to a union of them. - - A union is what an endpoint covering several resource types binds, such - as the server root of :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`. - Anything that is not a resource type, a type variable in particular, is - left to the generic machinery. - """ - models = _model_union(model) - if models is None: - return super().__class_getitem__(model) # type: ignore[misc,no-any-return] - - cache_key = (cls, models) - if cache_key in _PATH_CACHE: - return _PATH_CACHE[cache_key] - - names = ", ".join(each.__name__ for each in models) - new_class = type(f"Path[{names}]", (cls,), {"__scim_models__": models}) - _PATH_CACHE[cache_key] = new_class - return new_class - def _resolving_model(self) -> "type[BaseModel] | None": """Return the bound model this path resolves against. @@ -232,33 +192,6 @@ def _resolving_model(self) -> "type[BaseModel] | None": return model return self.__scim_models__[0] - @classmethod - def __get_pydantic_core_schema__( - cls, - _source_type: type[Any], - _handler: GetCoreSchemaHandler, - ) -> core_schema.CoreSchema: - def validate_path(value: Any) -> "Path[Any]": - if not isinstance(value, str): - raise ValueError(f"Expected str or Path, got {type(value).__name__}") - try: - return cls(str(value)) - except InvalidPathException as exc: - raise exc.as_pydantic_error() from exc - - return core_schema.no_info_plain_validator_function( - validate_path, - serialization=core_schema.plain_serializer_function_ser_schema(str), - ) - - @classmethod - def __get_pydantic_json_schema__( - cls, - _core_schema: core_schema.CoreSchema, - _handler: GetJsonSchemaHandler, - ) -> JsonSchemaValue: - return {"type": "string"} - def __new__(cls, path: "str | Path[Any] | Template") -> "Path[Any]": text = _text(path) cls.check_syntax(text) From 5ecc0dd6e1ea5db00fa9f8b16fba5397adc381d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:45:58 +0200 Subject: [PATCH 05/14] feat: parse SearchRequest filters SearchRequest.filter is a ScimFilter instead of a str, so a malformed filter is rejected at validation time, as a pydantic error naming the field and carrying its scimType; it is a str subclass, so anything reading one still reads a string. Naming the resource types the endpoint serves, as in SearchRequest[User] or SearchRequest[User | Group], is what also refuses an attribute none of them declares, where an unparameterised request only has its syntax checked. --- doc/changelog.rst | 12 ++- scim2_models/messages/search_request.py | 44 ++++++++-- tests/test_filter_semantics.py | 23 +++++ tests/test_search_request.py | 108 +++++++++++++++++++++++- 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 248c458c..7d797c7e 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -16,12 +16,14 @@ Added interpolated path or filter is inserted as it stands. - :class:`~scim2_models.SearchRequest` and :class:`~scim2_models.ResponseParameters` take the resource type an endpoint serves, as in ``SearchRequest[User]``, which resolves - :attr:`~scim2_models.SearchRequest.sort_by`, + :attr:`~scim2_models.SearchRequest.filter`, :attr:`~scim2_models.SearchRequest.sort_by`, :attr:`~scim2_models.ResponseParameters.attributes` and :attr:`~scim2_models.ResponseParameters.excluded_attributes` against that model. An endpoint - covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so does - :class:`~scim2_models.Path`; a path resolves against the first type declaring it, so - ``sortBy`` answers on a root query too. + covering several of them takes a union, as in ``SearchRequest[User | Group]``, and so do + :class:`~scim2_models.ScimFilter` and :class:`~scim2_models.Path`. A filter attribute only + some of the types declare is valid, and evaluates to false on the others, which is what + :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` requires of a root query; a path resolves + against the first type declaring it, so ``sortBy`` answers on a root query too. - :meth:`Path.resolve ` resolves a path to the :class:`~scim2_models.ResolvedAttribute` it designates. - lark is a new dependency. @@ -34,6 +36,8 @@ Changed unknown entry of :attr:`~scim2_models.ResponseParameters.attributes` is ignored, an order cannot be: a ``sortBy`` left out answers an arbitrary order the client cannot tell from the one it asked for. +- :attr:`SearchRequest.filter ` is a :class:`~scim2_models.ScimFilter` instead of a + :class:`str`, so a malformed filter is rejected at validation time. - Paths are parsed with the :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` grammar instead of being checked character by character, so malformed paths such as ``emails[`` or ``userName ==``, which used to be accepted, are now rejected. diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 48d4a1e6..637e9531 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -4,7 +4,9 @@ from pydantic import field_validator +from ..exceptions import InvalidFilterException from ..exceptions import InvalidPathException +from ..filters import ScimFilter from ..path import Path from ..path import ResourceT from ..urn import URN @@ -16,16 +18,46 @@ class SearchRequest(Message, ResponseParameters[ResourceT], Generic[ResourceT]): """SearchRequest object defined at :rfc:`RFC7644 §3.4.3 <7644#section-3.4.3>`. Parameterising the request with the resource type an endpoint serves, as in - ``SearchRequest[User]``, resolves :attr:`sort_by` and the attributes of - :class:`~scim2_models.ResponseParameters` against that model. An endpoint - covering several types, such as the server root, takes a union of them, as - in ``SearchRequest[User | Group]``. + ``SearchRequest[User]`` for ``/Users`` and ``/Users/.search``, resolves + :attr:`filter` and :attr:`sort_by` against that model. An endpoint covering + several resource types, such as the server root and the ``/.search`` mounted + on it, names them all: ``SearchRequest[User | Group]``. An attribute only + some of them declare stays valid there, and evaluates to false on the + resources of the others, as + :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` requires. """ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:SearchRequest") - filter: str | None = None - """The filter string used to request a subset of resources.""" + filter: ScimFilter[ResourceT] | None = None + """The filter used to request a subset of resources. + + Assigning a string parses it, so a malformed filter is rejected at + validation time rather than by the server. On a parameterised request the + filter is checked against the model as well, unknown attributes included, + and is ready to be matched:: + + SearchRequest[User](filter='userName eq "bjensen"').filter.match(user) + + An unparameterised request only has its syntax checked, there being no + model to resolve attribute names against. + """ + + @field_validator("filter") + @classmethod + def _resolvable_filter( + cls, value: "ScimFilter[Any] | None" + ) -> "ScimFilter[Any] | None": + """Reject an attribute the bound model does not declare.""" + # Parameterising the request names the resource types the endpoint + # serves, which is what makes an attribute none of them declares a + # client error rather than something to evaluate to false. + if value is not None and value.models: + try: + value._validate_semantics() + except InvalidFilterException as exc: + raise exc.as_pydantic_error() from exc + return value sort_by: Path[ResourceT] | None = None """A string indicating the attribute whose value SHALL be used to order the diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py index ec408bb9..1d8a80c7 100644 --- a/tests/test_filter_semantics.py +++ b/tests/test_filter_semantics.py @@ -19,6 +19,7 @@ from scim2_models import ResolvedAttribute from scim2_models import Schema from scim2_models import ScimFilter +from scim2_models import SearchRequest from scim2_models import User from scim2_models.filters import AttrPath from scim2_models.filters import CompareOperator @@ -884,6 +885,27 @@ def visit_value_path(self, node): # --- Remaining corners --- +def test_a_filter_field_rejects_a_value_that_is_neither_string_nor_filter(): + + with pytest.raises(Exception, match="Expected str or ScimFilter"): + SearchRequest.model_validate({"filter": 42}) + + +def test_a_filter_field_accepts_a_string_and_a_filter(): + + assert SearchRequest(filter='userName eq "x"').filter == 'userName eq "x"' + assert ( + SearchRequest(filter=ScimFilter('userName eq "x"')).filter == 'userName eq "x"' + ) + + +def test_a_filter_field_rejects_a_malformed_filter(): + + with pytest.raises(ValidationError) as raised: + SearchRequest.model_validate({"filter": "nonsense @"}) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + def test_validating_a_negation_tolerantly_walks_its_operand(): ScimFilter[User]('not (nonexistent eq "x")')._validate_semantics(strict=False) @@ -1073,6 +1095,7 @@ def test_a_bound_filter_accepts_an_attribute_the_model_does_not_declare(): def test_an_unbound_filter_is_only_checked_for_syntax(): """Nothing resolves attribute names until a filter is bound to a model.""" assert ScimFilter("active gt true") == "active gt true" + assert SearchRequest(filter="active gt true").filter == "active gt true" def test_a_filter_binds_to_a_union_of_resource_types(): diff --git a/tests/test_search_request.py b/tests/test_search_request.py index c4e29d85..6b116ed2 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -1,6 +1,7 @@ import pytest from pydantic import ValidationError +from scim2_models import EnterpriseUser from scim2_models import Group from scim2_models import User from scim2_models.messages.search_request import SearchRequest @@ -298,11 +299,80 @@ def test_a_parameter_asking_for_an_attribute_accepts_a_reference(): assert request.sort_by == "members.$ref" +def test_a_parameterised_request_resolves_its_filter_against_the_model(): + request = SearchRequest[User].model_validate({"filter": 'userName eq "bjensen"'}) + assert request.filter.match(User(user_name="bjensen")) + + +def test_a_parameterised_request_rejects_a_comparison_its_attribute_cannot_take(): + with pytest.raises( + ValidationError, match="operator 'gt' cannot be applied" + ) as raised: + SearchRequest[User].model_validate({"filter": "active gt true"}) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + +def test_a_parameterised_request_rejects_an_attribute_the_model_does_not_declare(): + """Naming the served resource type is what makes this a client error rather than an empty page.""" + with pytest.raises(ValidationError, match="Field not found: nonexistent") as raised: + SearchRequest[User].model_validate({"filter": 'nonexistent eq "x"'}) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + +def test_a_parameterised_request_rejects_a_qualified_attribute_in_a_value_selection(): + """The path inside the brackets names a sub-attribute, which no URN qualifies. + + Prefixing it with a URN used to escape the resolution altogether, so the + filter was accepted and then matched nothing. + """ + for uri in ("urn:example:2.0:Thing", User.__schema__): + with pytest.raises(ValidationError, match="cannot qualify") as raised: + SearchRequest[User].model_validate( + {"filter": f'emails[{uri}:value eq "x"]'} + ) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + +def test_a_parameterised_request_declares_the_extensions_it_serves(): + payload = { + "filter": 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber eq "1"' + } + with pytest.raises( + ValidationError, match="Field not found: employeeNumber" + ) as raised: + SearchRequest[User].model_validate(payload) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + assert SearchRequest[User[EnterpriseUser]].model_validate(payload).filter + + +def test_a_parameterised_request_resolves_its_sort_by(): + request = SearchRequest[User].model_validate({"sortBy": "userName"}) + assert request.sort_by.field_name == "user_name" + + +def test_an_unparameterised_request_only_checks_the_filter_syntax(): + """§3.4.2.1 has an endpoint covering several resource types evaluate an undeclared attribute to false.""" + request = SearchRequest.model_validate({"filter": 'nonexistent eq "x"'}) + assert request.filter == 'nonexistent eq "x"' + assert request.filter.model is None + assert ( + SearchRequest.model_validate({"sortBy": "userName"}).sort_by.field_name is None + ) + + def test_a_request_covering_several_resource_types_takes_a_union(): """§3.4.2.1 has the server root cover every type it serves.""" request = SearchRequest[User | Group].model_validate( - {"sortBy": "userName", "attributes": "members"} + { + "filter": 'userName eq "bjensen" or members pr', + "sortBy": "userName", + "attributes": "members", + } ) + assert request.filter.models == (User, Group) + assert request.filter.match(User(user_name="bjensen")) + assert request.filter.match(Group(display_name="admins", members=[{"value": "u1"}])) assert request.sort_by.models == (User, Group) assert request.sort_by.field_name == "user_name" assert request.attributes[0].model is Group @@ -342,3 +412,39 @@ def test_a_parameterised_request_reports_a_sort_by_selecting_values_as_such(): """A selection is refused for what it is, before its attribute is looked up.""" with pytest.raises(ValidationError, match="not in attribute notation"): SearchRequest[User].model_validate({"sortBy": 'emails[type eq "work"]'}) + + +def test_a_union_request_rejects_an_attribute_no_resource_type_declares(): + with pytest.raises(ValidationError, match="Field not found: nonexistent") as raised: + SearchRequest[User | Group].model_validate({"filter": 'nonexistent eq "x"'}) + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + +def test_a_union_request_resolves_its_sort_by(): + """A root query sorts on an attribute only some of the types it serves declare.""" + request = SearchRequest[User | Group].model_validate({"sortBy": "userName"}) + assert request.sort_by.field_name == "user_name" + assert request.sort_by.model is User + + +def test_a_parameterised_request_resolves_its_attributes(): + """A server reads the attribute a client asked for, rather than its spelling.""" + request = SearchRequest[User].model_validate( + {"attributes": ["userName", "emails.value"]} + ) + assert [path.field_name for path in request.attributes] == ["user_name", "value"] + assert [path.model.__name__ for path in request.attributes] == ["User", "Email"] + + +def test_a_union_request_resolves_its_attributes(): + request = SearchRequest[User | Group].model_validate( + {"attributes": ["userName", "members"]} + ) + assert [path.model for path in request.attributes] == [User, Group] + + +def test_an_unparameterised_request_leaves_its_attributes_unresolved(): + """§3.9 makes no promise about an attribute a resource type does not declare.""" + request = SearchRequest.model_validate({"attributes": "userName"}) + assert request.attributes == ["userName"] + assert request.attributes[0].field_name is None From 407a5705afba96c94ab731d2144aa1aecf9ef9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:47:28 +0200 Subject: [PATCH 06/14] doc: filtering Adds a filters page covering the grammar, the operators, the deviations from the published ABNF and a worked transpiler built on FilterVisitor, and a tutorial section on filters and PATCH value selections. The framework guides now filter the collections they serve: a filter applies to the SCIM representation, so the store is mapped first, filtered, paginated last, and the root query binds every resource type it gathers. --- doc/filters.rst | 433 ++++++++++++++++++++++++ doc/guides/_examples/django_example.py | 25 +- doc/guides/_examples/fastapi_example.py | 16 +- doc/guides/_examples/flask_example.py | 16 +- doc/guides/_examples/integrations.py | 12 +- doc/guides/django.rst | 14 +- doc/guides/fastapi.rst | 14 +- doc/guides/flask.rst | 14 +- doc/guides/index.rst | 50 ++- doc/index.rst | 2 + doc/patch.rst | 134 ++++++++ doc/reference.rst | 30 ++ doc/tutorial.rst | 91 +++++ tests/test_doc_examples.py | 169 +++++++-- 14 files changed, 960 insertions(+), 60 deletions(-) create mode 100644 doc/filters.rst create mode 100644 doc/patch.rst diff --git a/doc/filters.rst b/doc/filters.rst new file mode 100644 index 00000000..1cd45353 --- /dev/null +++ b/doc/filters.rst @@ -0,0 +1,433 @@ +Advanced filters +---------------- + +This page picks up where the :ref:`Filters ` section of the tutorial leaves +off, and assumes you have read it. It covers three things: how to build a filter from values you +do not trust, how to turn a filter into a database query, and where the implemented grammar +departs from the published RFC. The second part is written for people implementing a SCIM +server, and assumes you can read SQL. The page does not describe the filter syntax itself, which +:rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` does. + +The examples below carry on from that section: + +.. doctest:: + + >>> from scim2_models import ScimFilter, User + + >>> user = User( + ... user_name="bjensen", + ... emails=[{"type": "work", "value": "bjensen@example.com"}], + ... ) + +Building a filter +================= + +The values a filter compares often come from outside: a name typed by a user, an identifier +read from another system. Pasted into the filter string as they are, they can change what the +filter means: + +.. doctest:: + + >>> untrusted = 'x" or userName pr or userName eq "y' + >>> forged = ScimFilter[User]('userName eq "%s"' % untrusted) + >>> forged.match(user) + True + +In this example, the caller wrote one comparison and got three. The filter now matches every +user that has a ``userName``. :meth:`ScimFilter.quote ` prevents +this: it turns a value into one literal, quotes included. Put that literal in a slot with no +quotes around it, and the value stays a value whatever it contains: + +.. doctest:: + + >>> quoted = ScimFilter[User](f"userName eq {ScimFilter.quote(untrusted)}") + >>> print(quoted) + userName eq "x\" or userName pr or userName eq \"y" + >>> quoted.match(user) + False + +``quote`` also takes a number, a boolean, :data:`None` or a :class:`~datetime.datetime`; its +documentation lists them. The literal works in a :class:`~scim2_models.Path` as well, since the +selection between its brackets is a filter. + +On Python 3.14, write the filter as a t-string and the constructor quotes every interpolated +value for you, in a filter as in a path: + +.. code-block:: python + + ScimFilter[User](t"userName eq {untrusted} and meta.lastModified gt {since}") + Path[Group](t"members[value eq {member_id}]") + +Quoting protects values only. You cannot escape an attribute name, because the grammar has no +quoted form for a name. Check a name that comes from outside instead. Build a +:class:`~scim2_models.Path` from it: the constructor refuses anything that is not a path, so it +rejects ``userName eq "admin" or userName`` before the name reaches the filter. A t-string +inserts a path as it stands, without quoting it: + +.. code-block:: python + + ScimFilter[User](t"{Path[User](attribute)} eq {value}") + +Binding the filter to a model does not replace that check. Binding refuses a name the model +does not declare, but a forged fragment can name declared attributes only. Binding also refuses +the name late: when a :class:`~scim2_models.SearchRequest` validates the filter, or when you +resolve a node. Creating the bound filter accepts the unknown name, because an endpoint that +serves several resource types must evaluate it to false rather than fail +(:rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`). + +You can also build a filter without writing any SCIM syntax. Expression nodes combine with the +Python boolean operators and render back to valid syntax, parentheses included. A +:class:`~scim2_models.filters.Comparison` quotes its value the same way, and an +:class:`~scim2_models.filters.AttrPath` refuses a name the grammar would not read as one: + +.. doctest:: + + >>> from scim2_models.filters import AttrPath, Comparison, CompareOperator, Present + + >>> work = Comparison(AttrPath("userName"), CompareOperator.eq, "bjensen") + >>> titled = Present(AttrPath("title")) + >>> print(work & titled) + userName eq "bjensen" and title pr + >>> print(~work | titled) + not (userName eq "bjensen") or title pr + + >>> ScimFilter[User](work & titled).match(user) + False + +.. _filter-transpiling: + +Writing a query +=============== + +Over a large collection, a filter has to become a query the database runs: a ``WHERE`` clause, +or its equivalent in another store. A server receives filters from its clients, in the query +parameter of a ``GET /Users?filter=…`` or in the +:attr:`SearchRequest.filter ` of a ``POST /Users/.search``. +The :doc:`guides/index` guides answer such a request with +:meth:`ScimFilter.match `: they load every resource and keep the +ones that match. That works while the store is small. Over a million users, every request reads +the whole table. + +scim2-models ships no SQL, but it gives you the parts you need to write that query yourself. +:class:`~scim2_models.ScimFilter` parses a filter when it is created, and keeps the result in +:attr:`ScimFilter.ast `: an abstract syntax tree, that is, a set of +nodes referencing each other. ``userName eq "bjensen" and title pr`` is a +:class:`~scim2_models.filters.LogicalExpr` node with two terms, a +:class:`~scim2_models.filters.Comparison` and a :class:`~scim2_models.filters.Present`, each holding +the :class:`~scim2_models.filters.AttrPath` it applies to: + +.. doctest:: + + >>> ScimFilter('userName eq "bjensen" and title pr').ast # doctest: +NORMALIZE_WHITESPACE + LogicalExpr(op=, + terms=(Comparison(attr_path=AttrPath(attr='userName', sub_attr=None, uri=None), + op=, value='bjensen'), + Present(attr_path=AttrPath(attr='title', sub_attr=None, uri=None)))) + +:class:`~scim2_models.filters.FilterVisitor` is a class that walks such a tree. You subclass it and +write what to do for each type of node. For a ``LogicalExpr``, write ``AND`` or ``OR`` between +its terms; for a ``Comparison``, ``column = ?``; for a ``Present``, ``column IS NOT NULL``. +:meth:`~scim2_models.filters.FilterVisitor.visit` dispatches a node to the method of its type and +returns what that method returns. A subclass that turns a filter into a query for another +system is called a transpiler below. + +Check the filter before you translate it. The constructor checks its syntax. Binding the filter +to a model checks the attributes it names, which +:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`] does for a query parameter. +The visitor below assumes a filter that passed both checks, so it can resolve a path without +handling an unknown attribute at every node. + +This first visitor writes the ``WHERE`` clause of a filter over single-valued attributes, each +stored in one column, ``meta.lastModified`` in ``meta_last_modified``. A node only carries the +*name* of an attribute. The visitor first resolves that name against the model, with +:meth:`ScimFilter.resolve_comparison ` or +:meth:`ScimFilter.resolve `. The result names the field holding +the attribute, and says whether the comparison ignores case. The values do not go into the SQL. +The visitor collects them in ``params``, which the database driver sends separately from the +query. :func:`~scim2_models.filters.coerce_value` converts each one beforehand, from the JSON value +the filter carries to the Python value the attribute holds. The visitor refuses whatever is +harder in SQL than in SCIM, instead of guessing: + +.. doctest:: + + >>> from scim2_models import Attribute + >>> from scim2_models.filters import FilterVisitor, LogicalOperator, coerce_value + + >>> SQL_OPERATORS = { + ... CompareOperator.eq: "=", + ... CompareOperator.ne: "<>", + ... CompareOperator.gt: ">", + ... CompareOperator.ge: ">=", + ... CompareOperator.lt: "<", + ... CompareOperator.le: "<=", + ... } + + >>> class SqlVisitor(FilterVisitor[str]): + ... def __init__(self, scim_filter): + ... self.filter = scim_filter + ... self.params = [] + ... + ... def column(self, resolved): + ... if resolved.is_multivalued: + ... raise NotImplementedError("a multi-valued attribute needs a subquery") + ... if resolved.sub_field_name: + ... return f"{resolved.field_name}_{resolved.sub_field_name}" + ... return resolved.field_name + ... + ... def casefolded(self, resolved): + ... """Only a string has a case, and only some strings ignore it.""" + ... scim_type = Attribute.Type.from_python(resolved.target_type) + ... return scim_type == Attribute.Type.string and not resolved.case_exact + ... + ... def visit_comparison(self, node): + ... if node.op not in SQL_OPERATORS: + ... raise NotImplementedError("a string operator needs LIKE and its escaping") + ... resolved = self.filter.resolve_comparison(node.attr_path) + ... self.params.append(coerce_value(resolved, node.value, node.op)) + ... column, value = self.column(resolved), "?" + ... if self.casefolded(resolved): + ... column, value = f"LOWER({column})", "LOWER(?)" + ... return f"{column} {SQL_OPERATORS[node.op]} {value}" + ... + ... def visit_present(self, node): + ... resolved = self.filter.resolve(node.attr_path) + ... return f"{self.column(resolved)} IS NOT NULL" + ... + ... def visit_not(self, node): + ... return f"NOT ({self.visit(node.expr)})" + ... + ... def visit_logical_expr(self, node): + ... joiner = " AND " if node.op == LogicalOperator.and_ else " OR " + ... return "(" + joiner.join(self.visit(term) for term in node.terms) + ")" + ... + ... def visit_value_path(self, node): + ... raise NotImplementedError("a value selection needs a subquery") + + >>> def to_sql(scim_filter): + ... """Return the ``WHERE`` clause of a filter, and the values it binds.""" + ... visitor = SqlVisitor(scim_filter) + ... return visitor.visit(scim_filter.ast), visitor.params + +A comparison on a string attribute reads a column and ignores case. A presence test checks the +column for ``NULL``: + +.. doctest:: + + >>> where, params = to_sql(ScimFilter[User]('userName eq "bjensen" and title pr')) + >>> print(where) + (LOWER(user_name) = LOWER(?) AND title IS NOT NULL) + >>> params + ['bjensen'] + +A dateTime is passed as the :class:`~datetime.datetime` the attribute holds, not as the string +the filter spelled. A boolean has no case, so the visitor compares it as it is: + +.. doctest:: + + >>> scim_filter = ScimFilter[User]( + ... 'meta.lastModified gt "2024-01-01T00:00:00Z" and active eq true' + ... ) + >>> where, params = to_sql(scim_filter) + >>> print(where) + (meta_last_modified > ? AND active = ?) + >>> [type(param).__name__ for param in params] + ['datetime', 'bool'] + +The example refuses five things a complete transpiler has to handle: + +- **A multi-valued attribute**, stored in a table of its own. A comparison reaches it through a + correlated subquery, such as + ``EXISTS (SELECT 1 FROM emails WHERE emails.parent_id = resource.id AND …)``. +- **A value selection.** Its whole filter goes inside that one subquery, with each of its paths + scoped to the entry. +- **``ne``**, which holds when *no* value matches, as :ref:`filter-deviations` explains. On a + multi-valued attribute it negates the whole subquery. On any attribute it needs an ``IS NULL`` + guard, because SQL discards a row where the column is ``NULL``, while a missing attribute is + not equal to anything. +- **The string operators**, which ``LIKE`` implements once the ``%`` and ``_`` in the value are + escaped. Otherwise ``title co "100%"`` also selects ``"1000 Files"``. +- **The case**, which ``LOWER()`` folds over ASCII only, where :rfc:`7643` asks for Unicode + folding. :ref:`filter-deviations` describes the difference. + +Each of these mistakes produces valid SQL that answers a different question than the filter +asked. :meth:`ScimFilter.match ` walks the same tree through the +same resolution, on Python objects instead of on a database. Run both over the same resources +and compare the results. That check is the test suite of a transpiler. Here it is over an +in-memory SQLite table: + +.. doctest:: + + >>> import sqlite3 + + >>> def store(users): + ... """Load the resources in a table, one column per attribute.""" + ... connection = sqlite3.connect(":memory:") + ... connection.execute("CREATE TABLE users (id, user_name, title)") + ... connection.executemany( + ... "INSERT INTO users VALUES (?, ?, ?)", + ... [(user.id, user.user_name, user.title) for user in users], + ... ) + ... return connection + + >>> users = [ + ... User(id="1", user_name="bjensen", title="Manager"), + ... User(id="2", user_name="mgarcia"), + ... ] + >>> connection = store(users) + + >>> def query(scim_filter): + ... where, params = to_sql(scim_filter) + ... rows = connection.execute(f"SELECT id FROM users WHERE {where}", params) + ... return [row[0] for row in rows] + + >>> def evaluate(scim_filter): + ... return [user.id for user in users if scim_filter.match(user)] + + >>> scim_filter = ScimFilter[User]('title pr and userName eq "BJENSEN"') + >>> query(scim_filter) + ['1'] + >>> evaluate(scim_filter) + ['1'] + +A search request also carries a ``sortBy`` parameter. +:attr:`SearchRequest.sort_by ` is a +:class:`~scim2_models.Path`, and it resolves the same way. The attribute it resolves to names +the column an ``ORDER BY`` sorts on. The column alone does not settle the order: the case, the +missing values and the multi-valued attributes each have a rule of their own in +:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>`. + +.. _filter-deviations: + +Deviations from the published RFC +================================= + +:rfc:`7644` publishes its grammar in Augmented Backus–Naur Form (ABNF), and that grammar has +several defects. The grammar implemented here applies the errata that correct them. The table +gives the status each erratum had when it was applied. An erratum is *reported* when someone +submits it, *verified* once the RFC editors confirm the error, and *held for document update* +when they find it worth considering in a future revision without settling it now. None of the +errata correcting the grammar is verified yet, and a reported one may still be rejected. + +.. list-table:: Errata applied to the grammar + :header-rows: 1 + :widths: 10 20 70 + + * - Errata + - Status + - Correction + * - `4670 `_ + - Held for document update + - The published order of precedence is reversed. Attribute operators take precedence over + logical ones, ``not`` over ``and``, and ``and`` over ``or``. + * - `4690 `_, + `7322 `_ + - Held for document update, reported + - The published rule lets a value selection nest another one, as in + ``emails[type eq "work" and emails[type eq "home"]]``. 4690 closes the recursion but also + forbids the parentheses and the three-term expressions implementations write, and 7322, + which the grammar follows, restores them. + * - `7319 `_ + - Reported + - The grammar forbids a space between ``not`` and its parenthesis, which the examples of + the RFC itself use. The parser accepts both ``not (x pr)`` and ``not(x pr)``. + * - `7122 `_ + - Held for document update + - The ``PATH`` rule of a PATCH operation lacks ``attrExp``. It is the only way to target a + value of a multi-valued attribute that is not complex, such as ``schemas eq "urn:…"``. + * - `8924 `_ + - Reported + - ``ATTRNAME`` excludes ``$ref`` although RFC 7643 uses it. The parser accepts a leading + ``$``. + * - `6001 `_, + `8472 `_ + - Held for document update, verified + - Reference attributes are declared ``caseExact: false`` while §2.3.7 requires them to be + case-exact. scim2-models annotates ``profileUrl``, ``groups.value``, ``groups.$ref``, + ``members.value``, ``members.$ref`` and ``manager.$ref`` with + :attr:`CaseExact.true `, and + :meth:`~scim2_models.Resource.to_schema` reflects it. + +The grammar is also stricter than the published ABNF on five points. No deployment we know of +needs any of them: + +Schema URIs + §3.4.2.2 defines ``URI`` per :rfc:`Appendix A of RFC3986 <3986#appendix-A>`, which any + scheme satisfies. The grammar accepts Uniform Resource Names (URNs) only, since every schema + URI the SCIM specifications define is one. + +A sub-attribute before a value selection + ``valuePath = attrPath "[" valFilter "]"`` lets the ``attrPath`` carry a sub-attribute, so + ``emails.type[type eq "work"]`` is legal ABNF. The filter between the brackets addresses + the sub-attributes of the attribute in front of them, and + :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>` gives a sub-attribute none of its own. The form + has no possible meaning. The parser answers ``invalidFilter``, and ``invalidPath`` in a + PATCH path. + +A schema URN inside a value selection + ``valFilter`` reaches ``attrPath``, which carries an optional ``URI``, so + ``emails[urn:ietf:params:scim:schemas:core:2.0:User:type eq "work"]`` is legal ABNF. The + brackets address the sub-attributes of the attribute in front of them, and §3.10 qualifies + a sub-attribute through the attribute holding it: ``urn:…:User:emails.type``, never + ``urn:…:User:type``. A URN designates nothing there, and the parser answers + ``invalidFilter``. + +``$`` in an attribute name + :rfc:`RFC7643 §2.1 <7643#section-2.1>` puts ``$`` in ``nameChar``, so ``foo$bar`` is a + legal name. The parser accepts a leading ``$`` only. Errata 8924 asks for that, and + ``$ref`` needs it. + +Numbers out of range + A literal such as ``1e400`` would read as an infinity, which the ABNF has no syntax for, so + it could not be rendered back into a filter. The parser rejects it. + +The RFC leaves seven further choices open: + +``ne`` on a multi-valued attribute + §3.4.2.2 states that a filter matches "if any of the values" matches, without saying what + that means for a negation. :meth:`~scim2_models.ScimFilter.match` uses the universal + reading: ``emails.type ne "work"`` holds when *no* email is of type work. That agrees with + ``not (emails.type eq "work")``. + +Substring operators + ``co``, ``sw`` and ``ew`` match a fragment rather than a whole value, so + :func:`~scim2_models.filters.coerce_value` leaves their operand as it is instead of converting + it to the type of the attribute. The bound filter accepts ``emails[value co "example"]`` + even though ``"example"`` is not a valid email address on its own. + +Filtering on ``schemas`` + §3.4.2.2 lets a client query by schema extension with ``schemas eq "urn:…"``. A filter reads + the attribute as it stands, and :attr:`schemas ` holds what + a peer asserted, not what the model declares. A resource parsed from a payload listing its + extensions matches. One built in Python does not, since its extension URNs only appear on + serialization. Filter the serialized form when you expect clients to query that way. + +Comparing against ``null`` + ``null`` is a ``compValue`` the ABNF allows, but §3.4.2.2 does not say what comparing an + attribute to it means. ``attr eq null`` holds when the attribute is unassigned, and + ``attr ne null`` when it is assigned, so each is the negation of the other. An ordering + operator against ``null`` never matches. + +Comparing values of different types + ``meta.lastModified co "2024"`` compares a string with an instant. Raising would make a + filter over a heterogeneous collection unusable, so the comparison does not match instead. + A naive :class:`~datetime.datetime`, read from a store that drops offsets, is incomparable + with the aware one a filter carries in the same way: an ordering filter on it quietly + matches nothing. Make attributes typed ``dateTime`` timezone-aware before filtering them. + +Case-insensitive comparison + §3.4.2.2 defers to ``caseExact`` without saying how case is folded. + :meth:`~scim2_models.ScimFilter.match` applies Unicode case folding, on strings normalised + to Normalization Form C (NFC), so ``title eq "STRASSE"`` matches ``"Straße"``. A transpiler + emitting SQL ``LOWER()`` folds ASCII only, and departs from ``match`` on such a value. + +Coercion of comparison values + :func:`~scim2_models.filters.coerce_value` reads a comparison value as JSON, then converts it + to the type of the attribute. The conversion is the tolerant one of pydantic, so it reads + ``active eq "yes"`` and ``active eq 1`` as ``active eq true`` instead of rejecting them as + ``invalidFilter``. This tolerance makes the ``roles[primary eq "True"]`` that Microsoft + Entra ID emits usable. + +Beyond the errata, three tolerances make filters from real deployments usable. The parser +accepts irregular spacing around operators. It reads keywords in any case, as in ``AND`` or +``Eq``. And it accepts ``attrName[value eq "…"]``, the notation implementations use to address +the values of a multi-valued attribute that is not complex. diff --git a/doc/guides/_examples/django_example.py b/doc/guides/_examples/django_example.py index cc3d5e0d..2a95b2fa 100644 --- a/doc/guides/_examples/django_example.py +++ b/doc/guides/_examples/django_example.py @@ -241,7 +241,8 @@ def users_response(request, req, scim_ctx): """Return one page of users as a serialized SCIM ListResponse. A query applies to the SCIM representation rather than to the stored - records, so the store is mapped before it is ordered and paginated. + records, so the store is mapped before it is filtered, ordered and + paginated. :param request: The incoming request, used to build resource locations. :param req: The parsed query, whichever verb carried it. @@ -251,6 +252,8 @@ def users_response(request, req, scim_ctx): to_scim_user(record, resource_location(request, record)) for record in list_records() ] + if req.filter: + users = [user for user in users if req.filter.match(user)] total, page = page_of(users, req) response = ListResponse[User]( total_results=total, @@ -275,6 +278,8 @@ def get(self, request): req = SearchRequest[User].model_validate(request.GET.dict()) except ValidationError as error: return scim_validation_error(error) + except SCIMException as error: + return scim_exception_error(error) return users_response(request, req, Context.RESOURCE_QUERY_RESPONSE) @@ -320,7 +325,10 @@ def post(self, request): except ValidationError as error: return scim_validation_error(error) - return users_response(request, req, Context.SEARCH_RESPONSE) + try: + return users_response(request, req, Context.SEARCH_RESPONSE) + except SCIMException as error: + return scim_exception_error(error) # -- search-users-end -- @@ -329,9 +337,10 @@ class RootSearchView(SCIMView): """Query every resource type the server serves. :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has a root query cover them - all, so the request is parameterised with a union and the response gathers - groups alongside users. These guides expose no ``/Groups`` endpoint; the - groups they gather are read-only fixtures. + all, so the request is parameterised with a union: the filter is checked + against both models, and an attribute only one of them declares evaluates + to false on the other. These guides expose no ``/Groups`` endpoint, yet the + root query gathers groups all the same. """ def post(self, request): @@ -341,12 +350,18 @@ def post(self, request): ) except ValidationError as error: return scim_validation_error(error) + except SCIMException as error: + return scim_exception_error(error) resources = [ to_scim_user(record, resource_location(request, record)) for record in list_records() ] resources += [to_scim_group(record) for record in list_group_records()] + if req.filter: + resources = [ + resource for resource in resources if req.filter.match(resource) + ] total, page = page_of(resources, req) response = ListResponse[User | Group]( total_results=total, diff --git a/doc/guides/_examples/fastapi_example.py b/doc/guides/_examples/fastapi_example.py index fd56993d..30b760cf 100644 --- a/doc/guides/_examples/fastapi_example.py +++ b/doc/guides/_examples/fastapi_example.py @@ -224,7 +224,8 @@ def users_response(request, req, scim_ctx): """Return one page of users as a serialized SCIM ListResponse. A query applies to the SCIM representation rather than to the stored - records, so the store is mapped before it is ordered and paginated. + records, so the store is mapped before it is filtered, ordered and + paginated. :param request: The incoming request, used to build resource locations. :param req: The parsed query, whichever verb carried it. @@ -234,6 +235,8 @@ def users_response(request, req, scim_ctx): to_scim_user(record, resource_location(request, record)) for record in list_records() ] + if req.filter: + users = [user for user in users if req.filter.match(user)] total, page = page_of(users, req) response = ListResponse[User]( total_results=total, @@ -277,15 +280,20 @@ async def search_root( """Query every resource type the server serves. :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has a root query cover them - all, so the request is parameterised with a union and the response gathers - groups alongside users. These guides expose no ``/Groups`` endpoint; the - groups they gather are read-only fixtures. + all, so the request is parameterised with a union: the filter is checked + against both models, and an attribute only one of them declares evaluates + to false on the other. These guides expose no ``/Groups`` endpoint, yet the + root query gathers groups all the same. """ resources = [ to_scim_user(record, resource_location(request, record)) for record in list_records() ] resources += [to_scim_group(record) for record in list_group_records()] + if req.filter: + resources = [ + resource for resource in resources if req.filter.match(resource) + ] total, page = page_of(resources, req) response = ListResponse[User | Group]( total_results=total, diff --git a/doc/guides/_examples/flask_example.py b/doc/guides/_examples/flask_example.py index d52763ca..1823dc78 100644 --- a/doc/guides/_examples/flask_example.py +++ b/doc/guides/_examples/flask_example.py @@ -213,7 +213,8 @@ def users_response(req, scim_ctx): """Return one page of users as a serialized SCIM ListResponse. A query applies to the SCIM representation rather than to the stored - records, so the store is mapped before it is ordered and paginated. + records, so the store is mapped before it is filtered, ordered and + paginated. :param req: The parsed query, whichever verb carried it. :param scim_ctx: The context to serialize the response in. @@ -221,6 +222,8 @@ def users_response(req, scim_ctx): users = [ to_scim_user(record, resource_location(record)) for record in list_records() ] + if req.filter: + users = [user for user in users if req.filter.match(user)] total, page = page_of(users, req) response = ListResponse[User]( total_results=total, @@ -260,9 +263,10 @@ def search_root(): """Query every resource type the server serves. :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` has a root query cover them - all, so the request is parameterised with a union and the response gathers - groups alongside users. These guides expose no ``/Groups`` endpoint; the - groups they gather are read-only fixtures. + all, so the request is parameterised with a union: the filter is checked + against both models, and an attribute only one of them declares evaluates + to false on the other. These guides expose no ``/Groups`` endpoint, yet the + root query gathers groups all the same. """ req = SearchRequest[User | Group].model_validate_json( request.data, scim_ctx=Context.SEARCH_REQUEST @@ -271,6 +275,10 @@ def search_root(): to_scim_user(record, resource_location(record)) for record in list_records() ] resources += [to_scim_group(record) for record in list_group_records()] + if req.filter: + resources = [ + resource for resource in resources if req.filter.match(resource) + ] total, page = page_of(resources, req) response = ListResponse[User | Group]( total_results=total, diff --git a/doc/guides/_examples/integrations.py b/doc/guides/_examples/integrations.py index a5ef896d..34626dc2 100644 --- a/doc/guides/_examples/integrations.py +++ b/doc/guides/_examples/integrations.py @@ -179,7 +179,11 @@ def to_scim_user(record, location=None): user_name=record["user_name"], display_name=record.get("display_name"), active=record.get("active", True), - emails=[User.Emails(value=record["email"])] if record.get("email") else None, + emails=( + [User.Emails(value=record["email"], type=record.get("email_type"))] + if record.get("email") + else None + ), meta=Meta( resource_type="User", version=make_etag(record), @@ -192,12 +196,14 @@ def to_scim_user(record, location=None): def from_scim_user(scim_user): """Convert a validated SCIM payload into the application shape.""" + email = scim_user.emails[0] if scim_user.emails else None return { "id": scim_user.id, "user_name": scim_user.user_name, "display_name": scim_user.display_name, "active": True if scim_user.active is None else scim_user.active, - "email": scim_user.emails[0].value if scim_user.emails else None, + "email": email.value if email else None, + "email_type": str(email.type) if email and email.type else None, } @@ -258,7 +264,7 @@ def get_resource_type(resource_type_id): service_provider_config = ServiceProviderConfig( patch=Patch(supported=True), bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), - filter=Filter(supported=False, max_results=0), + filter=Filter(supported=True, max_results=MAX_RESULTS), change_password=ChangePassword(supported=False), sort=Sort(supported=True), etag=ETag(supported=True), diff --git a/doc/guides/django.rst b/doc/guides/django.rst index ca130f6f..fe057ed0 100644 --- a/doc/guides/django.rst +++ b/doc/guides/django.rst @@ -134,10 +134,10 @@ Collection ``UsersView`` handles ``GET /Users`` and ``POST /Users``. For ``GET``, parse the query parameters with -:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], order and page the mapped -resources as described in :ref:`guides-sorting`, then wrap the page in a -:class:`~scim2_models.ListResponse` serialized with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], keep the resources the +filter accepts as described in :ref:`guides-filtering`, order and page them as described in +:ref:`guides-sorting`, then wrap the page in a :class:`~scim2_models.ListResponse` serialized +with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. ``req.attributes`` and ``req.excluded_attributes`` are passed to :meth:`~scim2_models.BaseModel.model_dump` to apply the ``attributes`` and ``excludedAttributes`` query parameters to each embedded resource. @@ -173,6 +173,12 @@ indicates that all resources within the server SHALL be included, subject to fil endpoint gathers each type it serves and answers with a :class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. +Binding the request to that same union checks the filter against both models. An attribute +only one of them declares is valid, and evaluates to false on the other, as §3.4.2.1 requires: +"for filtered attributes that are not part of a particular resource type, the service provider +SHALL treat the attribute as if there is no attribute value". So ``userName pr`` keeps the +users, and the request refuses an attribute neither model declares. + These guides expose no ``/Groups`` endpoints; the groups the root query gathers are read-only fixtures, enough to show a heterogeneous collection. diff --git a/doc/guides/fastapi.rst b/doc/guides/fastapi.rst index 4bd544e5..51ebcdb4 100644 --- a/doc/guides/fastapi.rst +++ b/doc/guides/fastapi.rst @@ -148,10 +148,10 @@ GET /Users ^^^^^^^^^^ Parse the query parameters with -:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], order and page the mapped -resources as described in :ref:`guides-sorting`, then wrap the page in a -:class:`~scim2_models.ListResponse` serialized with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], keep the resources the +filter accepts as described in :ref:`guides-filtering`, order and page them as described in +:ref:`guides-sorting`, then wrap the page in a :class:`~scim2_models.ListResponse` serialized +with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. Pass ``req.attributes`` and ``req.excluded_attributes`` to :meth:`~scim2_models.BaseModel.model_dump_json` so that the ``attributes`` and ``excludedAttributes`` query parameters are applied to each embedded resource. @@ -184,6 +184,12 @@ indicates that all resources within the server SHALL be included, subject to fil endpoint gathers each type it serves and answers with a :class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. +Binding the request to that same union checks the filter against both models. An attribute +only one of them declares is valid, and evaluates to false on the other, as §3.4.2.1 requires: +"for filtered attributes that are not part of a particular resource type, the service provider +SHALL treat the attribute as if there is no attribute value". So ``userName pr`` keeps the +users, and the request refuses an attribute neither model declares. + These guides expose no ``/Groups`` endpoints; the groups the root query gathers are read-only fixtures, enough to show a heterogeneous collection. diff --git a/doc/guides/flask.rst b/doc/guides/flask.rst index 532b3a92..46d9aac3 100644 --- a/doc/guides/flask.rst +++ b/doc/guides/flask.rst @@ -138,10 +138,10 @@ GET /Users ^^^^^^^^^^ Parse the query parameters with -:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], order and page the mapped -resources as described in :ref:`guides-sorting`, then wrap the page in a -:class:`~scim2_models.ListResponse` serialized with -:attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. +:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], keep the resources the +filter accepts as described in :ref:`guides-filtering`, order and page them as described in +:ref:`guides-sorting`, then wrap the page in a :class:`~scim2_models.ListResponse` serialized +with :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE`. Pass ``req.attributes`` and ``req.excluded_attributes`` to :meth:`~scim2_models.BaseModel.model_dump_json` so that the ``attributes`` and ``excludedAttributes`` query parameters are applied to each embedded resource. @@ -174,6 +174,12 @@ indicates that all resources within the server SHALL be included, subject to fil endpoint gathers each type it serves and answers with a :class:`~scim2_models.ListResponse`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. +Binding the request to that same union checks the filter against both models. An attribute +only one of them declares is valid, and evaluates to false on the other, as §3.4.2.1 requires: +"for filtered attributes that are not part of a particular resource type, the service provider +SHALL treat the attribute as if there is no attribute value". So ``userName pr`` keeps the +users, and the request refuses an attribute neither model declares. + These guides expose no ``/Groups`` endpoints; the groups the root query gathers are read-only fixtures, enough to show a heterogeneous collection. diff --git a/doc/guides/index.rst b/doc/guides/index.rst index 3cdab009..1805cffc 100644 --- a/doc/guides/index.rst +++ b/doc/guides/index.rst @@ -36,6 +36,37 @@ This separation keeps the HTTP layer simple. The views work with SCIM resources, while the rest of the application can keep its own representation. +.. _guides-filtering: + +Filtering +--------- + +Clients narrow a collection with the ``filter`` query parameter +(:rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`), which +:attr:`SearchRequest.filter ` parses. Name the resource type +the endpoint serves, with :class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], +and the request checks the filter against that model as well. It refuses an unknown attribute, +or a comparison an attribute does not accept, while it validates the query parameters. A +malformed filter thus answers ``400`` instead of an empty page, and a valid one comes out ready +to match. + +:rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` asks something else of an endpoint covering +several resource types, such as the server root: there, "a presence or equality filter for an +undefined attribute evaluates to false". Name them all, with +:class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User` | :class:`~scim2_models.Group`]. +An attribute only some of them declare stays valid, and evaluates to false on the resources of +the others. An attribute none of them declares is still refused. + +A filter applies to the SCIM representation and not to the stored records, so the endpoints +map the store first, keep the resources :meth:`ScimFilter.match ` accepts, and +paginate last. ``totalResults`` therefore counts what the filter kept, as +:rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>` requires, and a page never exceeds the bound +:attr:`Filter.max_results ` advertises. + +That order reads the whole store on every request, which an in-memory example can afford and a +database cannot. A server backed by one translates the filter into a query instead, and lets the +database filter and paginate; see :ref:`filter-transpiling`. + .. _guides-sorting: Ordering and paging collections @@ -48,15 +79,18 @@ serves, with :class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`] :attr:`Path.field_name ` instead of the attribute name a client spelled. -:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides the order in three ways the helper -follows: a string attribute is compared without its case unless it is annotated -:attr:`CaseExact.true `; a multi-valued attribute is compared on -the value of its ``primary`` entry, or the first one; and a resource with no value for the -attribute comes last when ascending, first when descending. +:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides the order in three ways, and the helper +follows them: + +- A string attribute is compared without its case, unless it is annotated + :attr:`CaseExact.true `. +- A multi-valued attribute is compared on the value of its ``primary`` entry, or of the first + one. +- A resource with no value for the attribute comes last when ascending, first when descending. -The second is why the value is not read straight off the path. ``emails.value`` designates the -value of *every* entry, where an order wants one value per resource, so ``sort_value`` picks -the entry before reading the sub-attribute from it. That makes ``sortBy=emails`` the same query +The second rule is why ``sort_value`` does not read the value straight off the path. ``emails.value`` designates the +value of *every* entry, where an order wants one value per resource, so the helper picks the +entry before reading the sub-attribute from it. That makes ``sortBy=emails`` the same query as ``sortBy=emails.value``, :rfc:`RFC7643 §2.4 <7643#section-2.4>` holding the significant value of a complex entry in its ``value`` sub-attribute, where a scalar entry is that value itself. diff --git a/doc/index.rst b/doc/index.rst index a514ef6b..8cc711aa 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -8,6 +8,8 @@ Table of contents :maxdepth: 2 tutorial + filters + patch guides/index reference contributing diff --git a/doc/patch.rst b/doc/patch.rst new file mode 100644 index 00000000..5e5615bf --- /dev/null +++ b/doc/patch.rst @@ -0,0 +1,134 @@ +Patch paths +----------- + +This page picks up where the :ref:`Patch operations ` section of the tutorial +leaves off, and assumes you have read it. It covers what a path can select, what happens when it +selects nothing, which error a rejected path answers, and how to use a path outside a PATCH +request. What an operation does to a whole attribute stays in the tutorial. + +Selecting values +================ + +:attr:`PatchOperation.path ` follows a grammar of its own, +:rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`, where a filter between brackets selects which +entries of a multi-valued attribute an operation applies to: + +.. doctest:: + + >>> from scim2_models import PatchOp, PatchOperation, User + + >>> user = User( + ... user_name="bjensen", + ... emails=[ + ... {"type": "work", "value": "work@example.com"}, + ... {"type": "home", "value": "home@example.com"}, + ... ], + ... ) + + >>> patch = PatchOp[User]( + ... operations=[ + ... PatchOperation( + ... op=PatchOperation.Op.replace_, + ... path='emails[type eq "work"].value', + ... value="new@example.com", + ... ) + ... ] + ... ) + >>> patch.patch(user) + True + >>> [email.value for email in user.emails] + ['new@example.com', 'home@example.com'] + +A selection matching nothing +============================ + +What a selection matching nothing means depends on the operation. For ``replace``, +:rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>` requires a ``noTarget`` failure, which +:class:`~scim2_models.NoTargetException` carries: + +.. doctest:: + + >>> patch = PatchOp[User]( + ... operations=[ + ... PatchOperation( + ... op=PatchOperation.Op.replace_, + ... path='emails[type eq "other"].value', + ... value="other@example.com", + ... ) + ... ] + ... ) + >>> patch.patch(user) + Traceback (most recent call last): + ... + scim2_models.exceptions.NoTargetException: no value of 'emails' matches the path filter + +For ``remove``, :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` asks for the opposite. Its +removal example states that "if the user was not a member of this group, no changes should be +made to the resource, and a success response should be returned". The operation is a no-op, and +reports that nothing changed: + +.. doctest:: + + >>> patch = PatchOp[User]( + ... operations=[ + ... PatchOperation( + ... op=PatchOperation.Op.remove, path='emails[type eq "other"]' + ... ) + ... ] + ... ) + >>> patch.patch(user) + False + +``add`` behaves the same way. :rfc:`RFC7644 §3.5.2.1 <7644#section-3.5.2.1>` does not say what +a selection matching nothing means for it, so the operation is a no-op and not a failure. +`Errata 8097 `_ asks the RFC to say whether ``add`` +accepts a value selection at all, since implementations differ on it. + +.. note:: + + Microsoft Entra ID sends ``add`` operations whose selection matches nothing, and expects the + entry to be created. scim2-models does not create it. + +Rejected paths +============== + +Which ``scimType`` a rejected path answers follows Table 9 of +:rfc:`RFC7644 §3.12 <7644#section-3.12>`. ``invalidPath`` covers "the ``path`` attribute was +invalid or malformed (see Figure 7)". It answers a path the grammar refuses, and an attribute the +model does not declare. Table 9 lists ``invalidFilter`` as applying to a "PATCH (Path Filter)", +so it answers what goes wrong between the brackets. That covers an unknown sub-attribute, a +comparison the attribute cannot take, and a selection over an attribute holding a single value. + +Paths outside PATCH +=================== + +The same selection is available on :class:`~scim2_models.Path` itself, through +:meth:`~scim2_models.Path.get`, :meth:`~scim2_models.Path.set` and +:meth:`~scim2_models.Path.delete`: + +.. doctest:: + + >>> from scim2_models import Path + + >>> Path[User]('emails[type eq "home"].value').get(user) + ['home@example.com'] + +A multi-valued attribute that is not complex holds plain values with no sub-attribute to +compare. A path addresses those either with a bare comparison, which errata 7122 adds to the +grammar as :ref:`filter-deviations` explains, or with the ``value`` convention implementations +use: + +.. doctest:: + + >>> user = User( + ... user_name="bjensen", + ... schemas=["urn:ietf:params:scim:schemas:core:2.0:User", "urn:a:b:c"], + ... ) + + >>> Path[User]('schemas eq "urn:a:b:c"').delete(user) + True + >>> user.schemas + ['urn:ietf:params:scim:schemas:core:2.0:User'] + + >>> Path[User]('schemas[value eq "urn:ietf:params:scim:schemas:core:2.0:User"]').get(user) + ['urn:ietf:params:scim:schemas:core:2.0:User'] diff --git a/doc/reference.rst b/doc/reference.rst index 3e9a2882..ad4d5bc2 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -15,3 +15,33 @@ This page presents all the models provided by scim2-models. .. automodule:: scim2_models :members: + +Filters +======= + +The filter abstract syntax tree and the tools to walk it, used to turn a SCIM +filter into a backend query. See :ref:`filter-transpiling` for a worked example. + +.. automodule:: scim2_models.filters + :members: + :exclude-members: ScimFilter + +.. currentmodule:: scim2_models.filters + +.. data:: ORDERING_OPERATORS + :type: frozenset[CompareOperator] + + Operators that impose an ordering, and are thus invalid on boolean and binary + attributes per :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`. + +.. data:: STRING_OPERATORS + :type: frozenset[CompareOperator] + + Operators that require a string operand. + +.. data:: PathNode + :type: AttrPath | ValuePath | Comparison | Present + + A parsed PATCH path, per the ``PATH`` rule of + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` as corrected by errata 7122: + ``PATH = attrPath / valuePath [subAttr] / attrExp``. diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 954af21e..219cd8c4 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -364,6 +364,92 @@ Extensions attributes are accessed with brackets, e.g. ``user[EnterpriseUser].em ... } +.. _tutorial-filters: + +Filters +======= + +:class:`~scim2_models.ScimFilter` parses the filters defined at :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`, +the ones a client sends in a ``filter`` query parameter or in +:attr:`SearchRequest.filter `. +A filter behaves as the string it was built from, and the constructor checks its syntax as +soon as it is created: + +.. doctest:: + + >>> from scim2_models import ScimFilter, User + + >>> ScimFilter('userName eq "bjensen"') == 'userName eq "bjensen"' + True + + >>> ScimFilter('userName eq') + Traceback (most recent call last): + ... + scim2_models.exceptions.InvalidFilterException: invalid syntax at column 10 + +Binding it to a model with :class:`~scim2_models.ScimFilter`\ [:class:`~scim2_models.User`] +checks it against that model too, and refuses an operator or a value the attribute cannot take +the same way: + +.. doctest:: + + >>> ScimFilter[User]("active gt true") + Traceback (most recent call last): + ... + scim2_models.exceptions.InvalidFilterException: operator 'gt' cannot be applied to the boolean attribute 'urn:ietf:params:scim:schemas:core:2.0:User:active' + +Matching resources +^^^^^^^^^^^^^^^^^^ + +Binding a filter to a model with :class:`~scim2_models.ScimFilter`\ [:class:`~scim2_models.User`] +resolves attribute names against that model, and lets you check whether a resource satisfies it: + +.. doctest:: + + >>> user = User( + ... user_name="bjensen", + ... emails=[{"type": "work", "value": "bjensen@example.com"}], + ... ) + + >>> ScimFilter[User]('emails[type eq "work"]').match(user) + True + >>> ScimFilter[User]('userName sw "bj" and title pr').match(user) + False + +An endpoint covering several resource types binds them all at once, and the filter matches +each resource against its own type. A server needs this to answer a query against its root, as +:doc:`guides/index` shows: + +.. doctest:: + + >>> from scim2_models import Group + + >>> scim_filter = ScimFilter[User | Group]("userName pr") + >>> scim_filter.match(user) + True + >>> scim_filter.match(Group(display_name="admins")) + False + +Comparing a multi-valued complex attribute without naming a sub-attribute compares the ``value`` +of its entries, which is how :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` uses the two forms +side by side. Presence keeps its own meaning, since ``pr`` matches a non-empty *node*: + +.. doctest:: + + >>> ScimFilter[User]('emails co "example.com"').match(user) + True + >>> ScimFilter[User]('emails.value co "example.com"').match(user) + True + + >>> without_value = User(user_name="bjensen", emails=[{"type": "work"}]) + >>> ScimFilter[User]("emails pr").match(without_value) + True + >>> ScimFilter[User]("emails.value pr").match(without_value) + False + +:doc:`filters` continues from here: how to build a filter from values you do not trust, how to +turn one into a database query, and where the grammar departs from the published RFC. + Errors and Exceptions ===================== @@ -596,6 +682,8 @@ modified. If an immutable attribute differs, a :class:`~scim2_models.MutabilityException` is raised. +.. _tutorial-patch: + Patch operations ================ @@ -636,6 +724,9 @@ The :meth:`~scim2_models.PatchOp.patch` method applies operations in sequence an context. Make sure to validate patch operations with the correct context to ensure proper validation of mutability and required constraints. +A path can select which entries of a multi-valued attribute an operation applies to. See +:doc:`patch`. + Bulk operations =============== diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 5d03125b..3c18ce45 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -38,7 +38,7 @@ def test_flask_example_smoke(): "userName": "bjensen@example.com", "displayName": "Barbara Jensen", "active": True, - "emails": [{"value": "bjensen@example.com"}], + "emails": [{"value": "bjensen@example.com", "type": "work"}], }, ) assert create_response.status_code == 201 @@ -78,12 +78,16 @@ def test_flask_example_smoke(): assert searched["Resources"][0]["userName"] == "bjensen@example.com" assert "displayName" not in searched["Resources"][0] - root_response = client.post( - "/scim/v2/.search", - json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]}, - ) - assert root_response.status_code == 200 - gathered = root_response.get_json() + def root_search(scim_filter=None): + body = {"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]} + if scim_filter: + body["filter"] = scim_filter + response = client.post("/scim/v2/.search", json=body) + assert response.status_code == 200 + return response.get_json() + + # the root query gathers both resource types + gathered = root_search() assert gathered["totalResults"] == 3 assert {resource["meta"]["resourceType"] for resource in gathered["Resources"]} == { "User", @@ -104,6 +108,15 @@ def test_flask_example_smoke(): for resource in sorted_root_response.get_json()["Resources"] ] == ["Administrators", "Auditors", "Babs"] + # an attribute only one type declares evaluates to false on the other + users_only = root_search("userName pr") + assert users_only["totalResults"] == 1 + assert users_only["Resources"][0]["userName"] == "bjensen@example.com" + + groups_only = root_search('displayName eq "Administrators"') + assert groups_only["totalResults"] == 1 + assert groups_only["Resources"][0]["meta"]["resourceType"] == "Group" + malformed_response = client.post( "/scim/v2/Users/.search", data="{not json", @@ -125,6 +138,31 @@ def test_flask_example_smoke(): assert "userName" in resources[0] assert "displayName" not in resources[0] + filtered_response = client.get( + "/scim/v2/Users", + query_string={"filter": 'emails[type eq "work" and value ew "@example.com"]'}, + ) + assert filtered_response.status_code == 200 + assert filtered_response.get_json()["totalResults"] == 1 + + unmatched_response = client.get( + "/scim/v2/Users", query_string={"filter": 'userName eq "nobody"'} + ) + assert unmatched_response.status_code == 200 + assert unmatched_response.get_json()["totalResults"] == 0 + + malformed_filter_response = client.get( + "/scim/v2/Users", query_string={"filter": "userName eq"} + ) + assert malformed_filter_response.status_code == 400 + assert malformed_filter_response.get_json()["scimType"] == "invalidFilter" + + unknown_attribute_response = client.get( + "/scim/v2/Users", query_string={"filter": 'unknownAttr eq "x"'} + ) + assert unknown_attribute_response.status_code == 400 + assert unknown_attribute_response.get_json()["scimType"] == "invalidFilter" + duplicate_response = client.post( "/scim/v2/Users", json={ @@ -328,7 +366,7 @@ def test_django_example_smoke(): "userName": "bjensen@example.com", "displayName": "Barbara Jensen", "active": True, - "emails": [{"value": "bjensen@example.com"}], + "emails": [{"value": "bjensen@example.com", "type": "work"}], } ), content_type="application/scim+json", @@ -384,20 +422,34 @@ def test_django_example_smoke(): assert searched["Resources"][0]["userName"] == "bjensen@example.com" assert "displayName" not in searched["Resources"][0] - root_response = client.post( - "/scim/v2/.search", - json.dumps( - {"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]} - ), - content_type="application/scim+json", - ) - assert root_response.status_code == 200 - gathered = json.loads(root_response.content) + def root_search(scim_filter=None): + body = {"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]} + if scim_filter: + body["filter"] = scim_filter + response = client.post( + "/scim/v2/.search", + json.dumps(body), + content_type="application/scim+json", + ) + assert response.status_code == 200 + return json.loads(response.content) + + # the root query gathers both resource types + gathered = root_search() assert gathered["totalResults"] == 3 assert { resource["meta"]["resourceType"] for resource in gathered["Resources"] } == {"User", "Group"} + # an attribute only one type declares evaluates to false on the other + users_only = root_search("userName pr") + assert users_only["totalResults"] == 1 + assert users_only["Resources"][0]["userName"] == "bjensen@example.com" + + groups_only = root_search('displayName eq "Administrators"') + assert groups_only["totalResults"] == 1 + assert groups_only["Resources"][0]["meta"]["resourceType"] == "Group" + malformed_response = client.post( "/scim/v2/Users/.search", "{not json", @@ -419,6 +471,35 @@ def test_django_example_smoke(): assert "userName" in resources[0] assert "displayName" not in resources[0] + filtered_response = client.get( + "/scim/v2/Users", + {"filter": 'emails[type eq "work" and value ew "@example.com"]'}, + ) + assert filtered_response.status_code == 200 + assert json.loads(filtered_response.content)["totalResults"] == 1 + + unmatched_response = client.get( + "/scim/v2/Users", {"filter": 'userName eq "nobody"'} + ) + assert unmatched_response.status_code == 200 + assert json.loads(unmatched_response.content)["totalResults"] == 0 + + malformed_filter_response = client.get( + "/scim/v2/Users", {"filter": "userName eq"} + ) + assert malformed_filter_response.status_code == 400 + assert json.loads(malformed_filter_response.content)["scimType"] == ( + "invalidFilter" + ) + + unknown_attribute_response = client.get( + "/scim/v2/Users", {"filter": 'unknownAttr eq "x"'} + ) + assert unknown_attribute_response.status_code == 400 + assert json.loads(unknown_attribute_response.content)["scimType"] == ( + "invalidFilter" + ) + duplicate_response = client.post( "/scim/v2/Users", data=json.dumps( @@ -465,7 +546,7 @@ def test_fastapi_example_smoke(): "userName": "bjensen@example.com", "displayName": "Barbara Jensen", "active": True, - "emails": [{"value": "bjensen@example.com"}], + "emails": [{"value": "bjensen@example.com", "type": "work"}], }, ) assert create_response.status_code == 201 @@ -505,18 +586,31 @@ def test_fastapi_example_smoke(): assert searched["Resources"][0]["userName"] == "bjensen@example.com" assert "displayName" not in searched["Resources"][0] - root_response = client.post( - "/scim/v2/.search", - json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]}, - ) - assert root_response.status_code == 200 - gathered = root_response.json() + def root_search(scim_filter=None): + body = {"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]} + if scim_filter: + body["filter"] = scim_filter + response = client.post("/scim/v2/.search", json=body) + assert response.status_code == 200 + return response.json() + + # the root query gathers both resource types + gathered = root_search() assert gathered["totalResults"] == 3 assert {resource["meta"]["resourceType"] for resource in gathered["Resources"]} == { "User", "Group", } + # an attribute only one type declares evaluates to false on the other + users_only = root_search("userName pr") + assert users_only["totalResults"] == 1 + assert users_only["Resources"][0]["userName"] == "bjensen@example.com" + + groups_only = root_search('displayName eq "Administrators"') + assert groups_only["totalResults"] == 1 + assert groups_only["Resources"][0]["meta"]["resourceType"] == "Group" + get_attributes_response = client.get( f"/scim/v2/Users/{user_id}?attributes=userName" ) @@ -530,6 +624,31 @@ def test_fastapi_example_smoke(): assert "userName" in resources[0] assert "displayName" not in resources[0] + filtered_response = client.get( + "/scim/v2/Users", + params={"filter": 'emails[type eq "work" and value ew "@example.com"]'}, + ) + assert filtered_response.status_code == 200 + assert filtered_response.json()["totalResults"] == 1 + + unmatched_response = client.get( + "/scim/v2/Users", params={"filter": 'userName eq "nobody"'} + ) + assert unmatched_response.status_code == 200 + assert unmatched_response.json()["totalResults"] == 0 + + malformed_filter_response = client.get( + "/scim/v2/Users", params={"filter": "userName eq"} + ) + assert malformed_filter_response.status_code == 400 + assert malformed_filter_response.json()["scimType"] == "invalidFilter" + + unknown_attribute_response = client.get( + "/scim/v2/Users", params={"filter": 'unknownAttr eq "x"'} + ) + assert unknown_attribute_response.status_code == 400 + assert unknown_attribute_response.json()["scimType"] == "invalidFilter" + duplicate_response = client.post( "/scim/v2/Users", json={ @@ -557,6 +676,8 @@ def test_fastapi_example_smoke(): [ ({"count": "abc"}, "invalidSyntax"), ({"attributes": 'emails[type eq "work"]'}, "invalidPath"), + ({"filter": "nonsense @"}, "invalidFilter"), + ({"sortBy": "nonexistent"}, "invalidPath"), ], ) def test_fastapi_example_answers_a_scim_error_to_a_refused_query_parameter( From 1b4d38658b6a1e64982b2c1a94b328ef5909f015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 13:48:40 +0200 Subject: [PATCH 07/14] doc: sqlalchemy guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the in-memory storage layer of the integration guides with a database, where the filter becomes a WHERE clause and the order an ORDER BY next to the pagination, so a page costs a query over the matching rows rather than a walk over all of them. The clause follows the three rules of RFC7644 §3.4.2.3, case folding included, and closes on the primary key so a page stays reproducible without a sortBy. Twenty-three filters and twelve orderings run both as a query and through match() and sort_resources, an oracle that caught SQLite's naive datetime, its case-folding LIKE, and a case test on str that skipped emails.value. --- doc/conftest.py | 13 + doc/filters.rst | 7 +- doc/guides/_examples/sqlalchemy_example.py | 378 +++++++++++++++++++++ doc/guides/index.rst | 12 + doc/guides/sqlalchemy.rst | 232 +++++++++++++ pyproject.toml | 5 + tests/test_doc_examples.py | 231 +++++++++++++ uv.lock | 121 +++++++ 8 files changed, 998 insertions(+), 1 deletion(-) create mode 100644 doc/conftest.py create mode 100644 doc/guides/_examples/sqlalchemy_example.py create mode 100644 doc/guides/sqlalchemy.rst diff --git a/doc/conftest.py b/doc/conftest.py new file mode 100644 index 00000000..ed2e856c --- /dev/null +++ b/doc/conftest.py @@ -0,0 +1,13 @@ +import pytest + +from doc.guides._examples.sqlalchemy_example import create_session_factory +from doc.guides._examples.sqlalchemy_example import from_scim_user +from doc.guides._examples.sqlalchemy_example import query_users + + +@pytest.fixture(autouse=True) +def sqlalchemy_example(doctest_namespace): + """Expose the functions of the SQLAlchemy guide to the doctests of the pages.""" + doctest_namespace["create_session_factory"] = create_session_factory + doctest_namespace["from_scim_user"] = from_scim_user + doctest_namespace["query_users"] = query_users diff --git a/doc/filters.rst b/doc/filters.rst index 1cd45353..990f90ab 100644 --- a/doc/filters.rst +++ b/doc/filters.rst @@ -289,12 +289,16 @@ in-memory SQLite table: >>> evaluate(scim_filter) ['1'] +The :doc:`guides/sqlalchemy` guide handles each of these cases on SQLAlchemy expressions, and +runs every filter of its test list through both. + A search request also carries a ``sortBy`` parameter. :attr:`SearchRequest.sort_by ` is a :class:`~scim2_models.Path`, and it resolves the same way. The attribute it resolves to names the column an ``ORDER BY`` sorts on. The column alone does not settle the order: the case, the missing values and the multi-valued attributes each have a rule of their own in -:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>`. +:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>`. The :doc:`guides/sqlalchemy` guide implements +them. .. _filter-deviations: @@ -413,6 +417,7 @@ Comparing values of different types A naive :class:`~datetime.datetime`, read from a store that drops offsets, is incomparable with the aware one a filter carries in the same way: an ordering filter on it quietly matches nothing. Make attributes typed ``dateTime`` timezone-aware before filtering them. + The :doc:`guides/sqlalchemy` guide shows where. Case-insensitive comparison §3.4.2.2 defers to ``caseExact`` without saying how case is folded. diff --git a/doc/guides/_examples/sqlalchemy_example.py b/doc/guides/_examples/sqlalchemy_example.py new file mode 100644 index 00000000..55fd714e --- /dev/null +++ b/doc/guides/_examples/sqlalchemy_example.py @@ -0,0 +1,378 @@ +import operator +from datetime import datetime +from datetime import timezone +from enum import Enum + +from sqlalchemy import DateTime +from sqlalchemy import ForeignKey +from sqlalchemy import and_ +from sqlalchemy import create_engine +from sqlalchemy import event +from sqlalchemy import func +from sqlalchemy import not_ +from sqlalchemy import nulls_first +from sqlalchemy import nulls_last +from sqlalchemy import or_ +from sqlalchemy import select +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship +from sqlalchemy.orm import sessionmaker + +from scim2_models import Attribute +from scim2_models import InvalidPathException +from scim2_models import Meta +from scim2_models import SearchRequest +from scim2_models import User +from scim2_models.filters import STRING_OPERATORS +from scim2_models.filters import AttrPath +from scim2_models.filters import CompareOperator +from scim2_models.filters import FilterVisitor +from scim2_models.filters import LogicalOperator +from scim2_models.filters import coerce_value + +from .integrations import MAX_RESULTS + + +# -- models-start -- +class Base(DeclarativeBase): + pass + + +class UserRecord(Base): + """A user, as the application stores it.""" + + __tablename__ = "user" + + id: Mapped[str] = mapped_column(primary_key=True) + user_name: Mapped[str] + display_name: Mapped[str | None] + title: Mapped[str | None] + active: Mapped[bool] = mapped_column(default=True) + last_modified: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + emails: Mapped[list["EmailRecord"]] = relationship( + cascade="all, delete-orphan", lazy="selectin" + ) + groups: Mapped[list["GroupRecord"]] = relationship( + cascade="all, delete-orphan", lazy="selectin" + ) + + +class EmailRecord(Base): + """One entry of the multi-valued ``emails`` attribute.""" + + __tablename__ = "email" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(ForeignKey("user.id")) + type: Mapped[str | None] + value: Mapped[str] + + +class GroupRecord(Base): + """One entry of the multi-valued ``groups`` attribute.""" + + __tablename__ = "user_group" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(ForeignKey("user.id")) + value: Mapped[str] + display: Mapped[str | None] + + +# -- models-end -- + + +# -- engine-start -- +def create_session_factory(url="sqlite://"): + """Create the engine and the tables, and return a session factory. + + A case-exact attribute compares with ``LIKE``, which SQLite folds on ASCII + unless this pragma is set. Other engines settle it with the column collation. + """ + engine = create_engine(url) + + if engine.dialect.name == "sqlite": + + @event.listens_for(engine, "connect") + def _case_sensitive_like(dbapi_connection, _record): + dbapi_connection.execute("PRAGMA case_sensitive_like = ON") + + Base.metadata.create_all(engine) + return sessionmaker(engine) + + +# -- engine-end -- + + +# -- mapping-start -- +def as_utc(value): + """Make a stored instant aware, since a SCIM ``dateTime`` carries an offset.""" + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +def to_scim_user(record, location=None): + """Convert a stored user into a SCIM User resource.""" + return User( + id=record.id, + user_name=record.user_name, + display_name=record.display_name, + title=record.title, + active=record.active, + emails=[ + User.Emails(type=email.type, value=email.value) for email in record.emails + ] + or None, + groups=[ + User.Groups(value=group.value, display=group.display) + for group in record.groups + ] + or None, + meta=Meta( + resource_type="User", + last_modified=as_utc(record.last_modified), + location=location, + ), + ) + + +def from_scim_user(scim_user): + """Convert a validated SCIM payload into stored rows.""" + return UserRecord( + id=scim_user.id, + user_name=scim_user.user_name, + display_name=scim_user.display_name, + title=scim_user.title, + active=True if scim_user.active is None else scim_user.active, + last_modified=datetime.now(timezone.utc), + emails=[ + EmailRecord(type=str(email.type) if email.type else None, value=email.value) + for email in scim_user.emails or [] + ], + groups=[ + GroupRecord(value=group.value, display=group.display) + for group in scim_user.groups or [] + ], + ) + + +# -- mapping-end -- + + +# -- columns-start -- +COLUMNS = { + ("id", None): (None, UserRecord.id), + ("user_name", None): (None, UserRecord.user_name), + ("display_name", None): (None, UserRecord.display_name), + ("title", None): (None, UserRecord.title), + ("active", None): (None, UserRecord.active), + ("meta", "last_modified"): (None, UserRecord.last_modified), + ("emails", None): (UserRecord.emails, None), + ("emails", "type"): (UserRecord.emails, EmailRecord.type), + ("emails", "value"): (UserRecord.emails, EmailRecord.value), + ("groups", None): (UserRecord.groups, None), + ("groups", "value"): (UserRecord.groups, GroupRecord.value), + ("groups", "display"): (UserRecord.groups, GroupRecord.display), +} +# -- columns-end -- + + +# -- visitor-start -- +COMPARISONS = { + CompareOperator.eq: operator.eq, + CompareOperator.ne: operator.ne, + CompareOperator.gt: operator.gt, + CompareOperator.ge: operator.ge, + CompareOperator.lt: operator.lt, + CompareOperator.le: operator.le, +} + +STRING_METHODS = { + CompareOperator.co: ("contains", "icontains"), + CompareOperator.sw: ("startswith", "istartswith"), + CompareOperator.ew: ("endswith", "iendswith"), +} + + +def casefolded(resolved): + """Only a string has a case, and only some strings ignore it.""" + scim_type = Attribute.Type.from_python(resolved.target_type) + return scim_type == Attribute.Type.string and not resolved.case_exact + + +class SqlAlchemyVisitor(FilterVisitor): + """Turn a parsed filter into a SQLAlchemy expression. + + The expression is meant for the ``WHERE`` clause of a ``select`` over + :class:`UserRecord`. + """ + + def __init__(self, scim_filter, scope=None): + self.filter = scim_filter + self.scope = scope + + def path(self, attr_path): + """Qualify a path with the attribute a value selection scopes it to.""" + if self.scope is None: + return attr_path + return AttrPath(self.scope, attr_path.attr) + + def target(self, resolved): + """Return the relationship and the column an attribute is stored in.""" + key = (resolved.field_name, resolved.sub_field_name) + if key not in COLUMNS: + raise InvalidPathException(path=str(resolved.urn)) + return COLUMNS[key] + + def bind(self, resolved, node): + """Return the comparison value in the type the column holds.""" + value = coerce_value(resolved, node.value, node.op) + if isinstance(value, Enum): + return str(value) + return value + + def condition(self, resolved, column, op, value): + """Compare a column, following the case sensitivity of the attribute.""" + folded = casefolded(resolved) + if op in STRING_OPERATORS: + method = STRING_METHODS[op][folded] + return getattr(column, method)(value, autoescape=True) + + left, right = column, value + if folded: + left, right = func.lower(column), func.lower(value) + + compared = COMPARISONS[op](left, right) + if op != CompareOperator.ne: + return compared + # SQL discards a NULL column, where a missing attribute is not equal. + return or_(column.is_(None), compared) + + def join(self, relationship_, condition, negated=False): + """Correlate a condition with the rows of a multi-valued attribute.""" + if relationship_ is None or self.scope is not None: + return condition + return ( + not_(relationship_.any(condition)) + if negated + else relationship_.any(condition) + ) + + def visit_comparison(self, node): + resolved = self.filter.resolve_comparison(self.path(node.attr_path)) + relationship_, column = self.target(resolved) + # ``ne`` holds when no value matches, so a multi-valued attribute negates the + # subquery instead of comparing inside it. A selection scopes it back to one entry. + negated = ( + node.op == CompareOperator.ne + and relationship_ is not None + and self.scope is None + ) + op = CompareOperator.eq if negated else node.op + value = self.bind(resolved, node) + condition = self.condition(resolved, column, op, value) + return self.join(relationship_, condition, negated=negated) + + def visit_present(self, node): + resolved = self.filter.resolve(self.path(node.attr_path)) + relationship_, column = self.target(resolved) + if column is None: + return relationship_.any() + return self.join(relationship_, column.is_not(None)) + + def visit_not(self, node): + return not_(self.visit(node.expr)) + + def visit_logical_expr(self, node): + combine = and_ if node.op == LogicalOperator.and_ else or_ + return combine(*(self.visit(term) for term in node.terms)) + + def visit_value_path(self, node): + resolved = self.filter.resolve(node.attr_path) + relationship_, _column = self.target(resolved) + scoped = SqlAlchemyVisitor(self.filter, scope=node.attr_path.attr) + return relationship_.any(scoped.visit(node.val_filter)) + + +# -- visitor-end -- + + +# -- sort-start -- +def sort_expression(sort_by, sort_order=None): + """Return the ``ORDER BY`` term a ``sortBy`` parameter names. + + A sub-attribute is looked up under the attribute holding it, the way the + filter does: ``meta.lastModified`` is stored in ``("meta", "last_modified")``. + + :param sort_by: The ``sortBy`` query parameter. + :param sort_order: The ``sortOrder`` query parameter, ascending by default. + :raises InvalidPathException: If the attribute is unknown, or is stored in + a table an ``ORDER BY`` over users cannot reach. + """ + resolved = sort_by.resolve() + relationship_, column = None, None + if resolved is not None: + relationship_, column = COLUMNS.get( + (resolved.field_name, resolved.sub_field_name), (None, None) + ) + if column is None or relationship_ is not None: + raise InvalidPathException( + path=str(sort_by), detail=f"Cannot sort on {sort_by!r}" + ) + + if casefolded(resolved): + column = func.lower(column) + + # "if there is no data for the specified sortBy value, they are sorted via + # the sortOrder parameter, i.e., they are ordered last if ascending and + # first if descending", which is the default of PostgreSQL and not of the + # engines taking NULL for the smallest value. + if sort_order == SearchRequest.SortOrder.descending: + return nulls_first(column.desc()) + return nulls_last(column.asc()) + + +# -- sort-end -- + + +# -- query-start -- +def query_users(session, search_request): + """Return the total number of matching users, and the requested page. + + Filtering, sorting and pagination all happen in the database, so + ``totalResults`` counts what the filter kept without reading the rows. + + :param session: The SQLAlchemy session to query. + :param search_request: The parsed query parameters. + :raises InvalidFilterException: If the filter does not apply to the model. + :raises InvalidPathException: If the sort attribute cannot be sorted on. + """ + statement = select(UserRecord) + + if search_request.filter: + scim_filter = search_request.filter + statement = statement.where(SqlAlchemyVisitor(scim_filter).visit(scim_filter.ast)) + + total = session.scalar( + select(func.count()).select_from(statement.subquery()) + ) + + order_by = [] + if search_request.sort_by: + order_by.append( + sort_expression(search_request.sort_by, search_request.sort_order) + ) + # A page is a slice of an ordered result. Rows sharing a sort key, or a + # query with no sortBy at all, leave OFFSET free to return one row twice + # and another never, so the primary key always closes the order. + statement = statement.order_by(*order_by, UserRecord.id) + + start = search_request.start_index_0 or 0 + count = search_request.count or MAX_RESULTS + statement = statement.offset(start).limit(min(count, MAX_RESULTS)) + return total, session.scalars(statement).all() + + +# -- query-end -- diff --git a/doc/guides/index.rst b/doc/guides/index.rst index 1805cffc..9eccdcb9 100644 --- a/doc/guides/index.rst +++ b/doc/guides/index.rst @@ -124,6 +124,18 @@ capabilities. :start-after: # -- discovery-start -- :end-before: # -- discovery-end -- +Storing resources in a database +------------------------------- + +The storage layer above keeps everything in a dictionary, which keeps the examples short. :doc:`sqlalchemy` replaces it with a database, where the filter becomes a ``WHERE`` +clause instead of a predicate applied to every mapped resource, and where sorting and +pagination happen next to it. + +.. toctree:: + :maxdepth: 1 + + sqlalchemy + Web frameworks -------------- diff --git a/doc/guides/sqlalchemy.rst b/doc/guides/sqlalchemy.rst new file mode 100644 index 00000000..12d08c6a --- /dev/null +++ b/doc/guides/sqlalchemy.rst @@ -0,0 +1,232 @@ +SQLAlchemy +---------- + +This guide replaces the in-memory storage layer of the :doc:`index` section with a database, +using `SQLAlchemy `_ and :mod:`scim2_models`. +It is orthogonal to the framework guides: the HTTP layer stays the same, whichever of +:doc:`flask`, :doc:`django` or :doc:`fastapi` it comes from. + +It is written for people who know the SQLAlchemy object-relational mapper (ORM) and can read +SQL, and it assumes you have read the :doc:`index` section and the :doc:`../filters` page. The +example runs on SQLAlchemy 2.0 and later, installed with ``pip install sqlalchemy``. It covers +:class:`~scim2_models.User` only, and stops at querying: creation, patching and deletion are +ordinary ORM work that SCIM does not weigh on. + +What changes is where the filter is applied. The other guides map every stored record to a +SCIM resource and keep the ones :meth:`ScimFilter.match ` accepts, which reads the +whole store on every request. Here the filter becomes a ``WHERE`` clause, and sorting and +pagination happen next to it, so a page costs a query over the matching rows and not a walk +over all of them. + +Models +====== + +A multi-valued attribute becomes a table of its own, keyed on the resource holding it. +Everything else is a column. + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: Stored models + :start-after: # -- models-start -- + :end-before: # -- models-end -- + +Engine +====== + +Whether a comparison respects case depends on the engine as much as on the schema. A case-exact +attribute compares with ``LIKE``, which SQLite folds on ASCII unless ``case_sensitive_like`` is +set; PostgreSQL and MySQL settle it with the collation of the column instead. + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: Engine and session factory + :start-after: # -- engine-start -- + :end-before: # -- engine-end -- + +Mapping application data to SCIM +================================ + +The conversion is the one of the :doc:`index` section, applied to ORM objects instead of +dictionaries. One detail needs a function of its own. + +.. warning:: + + SQLite gives back a naive :class:`~datetime.datetime`, where a SCIM ``dateTime`` carries an + offset (:rfc:`RFC7643 §2.3.5 <7643#section-2.3.5>`). A naive instant does not compare with + the value a filter holds, so ``meta.lastModified gt "…"`` would quietly match nothing. + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: Conversion between stored rows and SCIM resources + :start-after: # -- mapping-start -- + :end-before: # -- mapping-end -- + +Mapping attributes to columns +============================= + +A filter names SCIM attributes, such as ``userName`` or ``emails.value``. +:meth:`ScimFilter.resolve_comparison ` looks such a +name up on the model and returns the field it designates: ``user_name`` on +:class:`~scim2_models.User`, or ``value`` on the entries of ``emails``. Which column of which +table stores that field is the one thing scim2-models cannot know. The example writes it down in +a table, keyed on the field and its sub-field. Each entry gives the relationship holding the +attribute, when it lives in a table of its own, and the column to compare: + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: From a resolved attribute to a column + :start-after: # -- columns-start -- + :end-before: # -- columns-end -- + +A convention such as ``getattr(UserRecord, resolved.field_name)`` would spare the table, at the +cost of breaking as soon as a name diverges, and of saying nothing about which relationship an +entry lives in. An explicit table also decides what is *not* queryable: the visitor raises on an +attribute the table omits, instead of querying a column that does not exist. + +Transpiling a filter +==================== + +Turning the filter into a ``WHERE`` clause is the job of a ``FilterVisitor``, the class +:ref:`filter-transpiling` introduces: a subclass says what to emit for each node of the parsed +filter. The ``SqlAlchemyVisitor`` below emits SQLAlchemy expressions instead of SQL text: + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: Filter to SQLAlchemy expression + :start-after: # -- visitor-start -- + :end-before: # -- visitor-end -- + +SQLAlchemy expressions keep it short. The visitor of :ref:`filter-transpiling` refused a +multi-valued attribute, a value selection and the string operators, because each takes SQL that +is hard to write by hand. Here SQLAlchemy writes that SQL. In ``join``, +``relationship_.any(condition)`` is the subquery that looks for a matching entry in the table of +a multi-valued attribute, and ``not_()`` around it is the negation ``ne`` needs. In +``condition``, ``autoescape=True`` escapes the ``%`` and ``_`` a value may contain before a +``LIKE`` reads them as wildcards. And every value reaches the database as a query parameter, +never as text inside the query. + +Two decisions remain, and neither is automated: + +- **Case**, read from :attr:`~scim2_models.ResolvedAttribute.case_exact` and the type of the + attribute. ``icontains`` and ``contains`` differ by one letter and by which resources they + return. +- **The three-valued logic of SQL**, where ``NULL <> 'Manager'`` is ``NULL`` and not true. A + missing attribute is not equal to anything, so ``ne`` needs its ``IS NULL`` guard. + +The test on the case reads the SCIM type and not the Python one: ``emails.value`` is an +:class:`~pydantic.networks.EmailStr`, which is not a :class:`str` subclass. + +Sorting +======= + +:attr:`SearchRequest.sort_by ` is a :class:`~scim2_models.Path`, +already resolved against the model on a parameterised request. On a union, it resolves against +the first resource type declaring the attribute. ``sort_expression`` looks the path up in the +same table of columns as the filter does. A sub-attribute is filed under the attribute holding +it, so ``meta.lastModified`` is found at ``("meta", "last_modified")``. The remaining step turns +the column into an ``ORDER BY`` term, and :rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides +the order in three ways a bare ``ORDER BY column`` follows none of: + +- **Case.** A case-insensitive attribute sorts on ``lower(column)``. +- **Missing values.** They come "last if ascending and first if descending". +- **Multi-valued attributes.** They sort "by the value of the primary attribute, if any, or else + the first value in the list". The example refuses them. + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: From a ``sortBy`` to an ``ORDER BY`` term + :start-after: # -- sort-start -- + :end-before: # -- sort-end -- + +The case is the decision the ``WHERE`` clause already makes, from the same annotation. SQLite +orders with the ``BINARY`` collation, where every uppercase letter precedes every lowercase one, +so ``RSanchez`` would sort ahead of ``bjensen`` instead of behind ``mgarcia``. ``lower()`` costs +the plain index on the column, and a functional index on ``lower(column)`` gives it back. The +``lower()`` of SQLite folds ASCII only, where :meth:`str.casefold` folds everything, so the two +disagree on ``ÉLOÏSE``. PostgreSQL folds by the collation of the column, and a case-insensitive +International Components for Unicode (ICU) collation comes closest to the rule. + +PostgreSQL places missing values as the RFC asks by default, ``NULLS LAST`` ascending and +``NULLS FIRST`` descending. SQLite and MySQL take ``NULL`` for the smallest value instead, which +is right descending and wrong ascending. Naming the placement makes the query say what it means +whatever the engine does. MySQL and MariaDB have no ``NULLS`` clause and spell it +``ORDER BY column IS NULL, column``. + +The sort key of a multi-valued attribute lives in another table, so it takes a correlated scalar +subquery ordered on ``primary``. And "the first value in the list" means nothing for rows a +relationship gives no order to, short of storing that order in a column of its own. The example +refuses a ``sortBy`` on such an attribute, as it refuses a filter on an attribute it does not +map. + +A fourth rule belongs to pagination and costs as little. A page is a slice of an ordered result, +so ``LIMIT`` and ``OFFSET`` need a total order to slice twice the same way. Rows sharing a sort +key, and a query carrying no ``sortBy`` at all, leave the engine free to return one row on two +pages and another on none. Closing the clause with the primary key makes a page reproducible. + +Querying +======== + +``totalResults`` counts what the filter kept, per :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`, +so the count runs on the filtered statement before it is paginated. ``MAX_RESULTS`` is the bound +of the :doc:`index` section, the one the :class:`~scim2_models.ServiceProviderConfig` advertises. + +.. literalinclude:: _examples/sqlalchemy_example.py + :language: python + :caption: Filtering, sorting and paginating + :start-after: # -- query-start -- + :end-before: # -- query-end -- + +Two users stored through ``from_scim_user`` are enough to see it run. The filter keeps one of +them, and ``totalResults`` counts it: + +.. testsetup:: + + from doc.guides._examples.sqlalchemy_example import create_session_factory + from doc.guides._examples.sqlalchemy_example import from_scim_user + from doc.guides._examples.sqlalchemy_example import query_users + +.. doctest:: + + >>> from scim2_models import SearchRequest, User + + >>> Session = create_session_factory() + >>> with Session() as session: + ... session.add(from_scim_user(User(id="1", user_name="bjensen", title="Manager"))) + ... session.add(from_scim_user(User(id="2", user_name="mgarcia"))) + ... session.commit() + ... request = SearchRequest[User](filter='title pr and userName eq "BJENSEN"') + ... total, page = query_users(session, request) + >>> total, [record.user_name for record in page] + (1, ['bjensen']) + +Checking it against the evaluator +================================= + +:meth:`ScimFilter.match ` walks the same tree through the same resolution, on +Python objects instead of on a database, so it answers the question the query is meant to +answer. Running both over the same resources tells a mapping mistake from a correct query. Do it +for any mapping written by hand. + +The test suite of this documentation runs its whole list of filters that way. Writing it caught +three defects in this example that had passed review: the naive :class:`~datetime.datetime` +above, the case-folding ``LIKE`` of SQLite, and a case test written on :class:`str` that +silently skipped ``emails.value``. The test is reproduced here as it stands: + +.. literalinclude:: ../../tests/test_doc_examples.py + :language: python + :caption: Comparing the query to the evaluator + :start-after: # -- oracle-start -- + :end-before: # -- oracle-end -- + +The order answers to the same treatment. ``sort_resources``, the helper of the :doc:`index` +section, applies the rules of §3.4.2.3 to Python values, and comparing the two over six +attributes in both orders says whether an ``ORDER BY`` implements them. It caught two defects +of its own. The example refused a ``sortBy`` naming a sub-attribute although its column is +mapped, and the suite asserted the ``BINARY`` order of SQLite for ``sortBy=userName`` as though +it were the one :rfc:`7644` asks for. + +.. literalinclude:: ../../tests/test_doc_examples.py + :language: python + :caption: Comparing the order to the helper + :start-after: # -- sort-oracle-start -- + :end-before: # -- sort-oracle-end -- diff --git a/pyproject.toml b/pyproject.toml index dabfecd7..96f0af3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "pytest-cov>=6.0.0", "prek>=0.1.0", "starlette>=1.2.0", + "sqlalchemy>=2.0", "tox-uv>=1.16.0", ] doc = [ @@ -117,6 +118,10 @@ docstring-code-format = true addopts = "--doctest-modules --doctest-glob='*.rst'" doctest_optionflags= "ALLOW_UNICODE ELLIPSIS" +[tool.codespell] +# ``selectin`` is the SQLAlchemy relationship loading strategy. +ignore-words-list = "selectin" + [tool.mypy] plugins = [ "pydantic.mypy" diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 3c18ce45..3c74a0d0 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -5,11 +5,24 @@ flask = pytest.importorskip("flask") django = pytest.importorskip("django") fastapi = pytest.importorskip("fastapi") +sqlalchemy = pytest.importorskip("sqlalchemy") + +from datetime import datetime # noqa: E402 +from datetime import timezone # noqa: E402 + +from pydantic import ValidationError # noqa: E402 from doc.guides._examples.integrations import sort_resources # noqa: E402 from doc.guides._examples.integrations import sort_value # noqa: E402 +from doc.guides._examples.sqlalchemy_example import EmailRecord # noqa: E402 +from doc.guides._examples.sqlalchemy_example import GroupRecord # noqa: E402 +from doc.guides._examples.sqlalchemy_example import UserRecord # noqa: E402 +from doc.guides._examples.sqlalchemy_example import create_session_factory # noqa: E402 +from doc.guides._examples.sqlalchemy_example import query_users # noqa: E402 +from doc.guides._examples.sqlalchemy_example import to_scim_user # noqa: E402 from scim2_models import EnterpriseUser # noqa: E402 from scim2_models import InvalidPathException # noqa: E402 +from scim2_models import ScimFilter # noqa: E402 from scim2_models import SearchRequest # noqa: E402 from scim2_models import User # noqa: E402 @@ -699,3 +712,221 @@ def test_fastapi_example_answers_a_scim_error_to_a_refused_query_parameter( assert response.status_code == 400 assert response.headers["Content-Type"] == "application/scim+json" assert response.json()["scimType"] == scim_type + + +def sqlalchemy_records(): + """Build fresh rows, since an ORM object belongs to the session storing it.""" + return [ + UserRecord( + id="1", + user_name="bjensen", + title="Manager", + active=True, + last_modified=datetime(2024, 6, 1, tzinfo=timezone.utc), + emails=[EmailRecord(type="work", value="bjensen@example.com")], + groups=[GroupRecord(value="2819c223-7f76", display="Tour Guides")], + ), + UserRecord( + id="2", + user_name="RSanchez", + active=False, + last_modified=datetime(2023, 1, 15, tzinfo=timezone.utc), + emails=[EmailRecord(type="home", value="rick@example.org")], + groups=[GroupRecord(value="2819C223-7F76", display="Tour Guides")], + ), + UserRecord( + id="3", + user_name="jsmith", + title="Engineer", + active=True, + last_modified=datetime(2025, 3, 20, tzinfo=timezone.utc), + emails=[EmailRecord(type="Work", value="J.Smith@Example.com")], + ), + UserRecord( + id="4", + user_name="dpotter", + title="100% remote", + active=True, + last_modified=datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + UserRecord( + id="5", + user_name="mgarcia", + title="1000 Files", + active=True, + last_modified=datetime(2025, 2, 1, tzinfo=timezone.utc), + ), + ] + + +SQLALCHEMY_FILTERS = [ + 'userName eq "bjensen"', + 'userName eq "BJENSEN"', + 'userName sw "b" and title pr', + 'emails[type eq "work" and value ew "@example.com"]', + 'emails[type eq "WORK"]', + 'emails.value co "Example"', + 'groups co "2819c223"', + 'groups co "2819C223"', + 'groups.display eq "tour guides"', + "active eq true", + 'meta.lastModified gt "2024-01-01T00:00:00Z"', + "not (title pr)", + "emails pr", + "not (emails pr)", + 'userName eq "bjensen" or title eq "Engineer"', + 'emails[type eq "home"] and active eq false', + 'title co "100%"', + 'title co "100"', + 'title ne "Manager"', + 'userName ne "bjensen"', + 'emails.type ne "work"', + 'emails[type ne "work"]', + 'groups ne "2819c223"', +] + + +@pytest.fixture +def sqlalchemy_session(): + session_factory = create_session_factory() + with session_factory() as session: + session.add_all(sqlalchemy_records()) + session.commit() + engine = session.get_bind() + yield session + engine.dispose() + + +# -- oracle-start -- +def test_sqlalchemy_queries_select_what_the_evaluator_selects(sqlalchemy_session): + """The generated query and ``match`` answer the same question. + + Both walk the same tree through the same resolution, so a difference is a + defect of the query rather than of the filter. + """ + stored = sqlalchemy_session.scalars(sqlalchemy.select(UserRecord)).all() + scim_users = [to_scim_user(record) for record in stored] + + for expression in SQLALCHEMY_FILTERS: + scim_filter = ScimFilter[User](expression) + total, page = query_users( + sqlalchemy_session, SearchRequest[User](filter=expression) + ) + evaluated = sorted(user.id for user in scim_users if scim_filter.match(user)) + assert sorted(record.id for record in page) == evaluated, expression + assert total == len(evaluated), expression + + +# -- oracle-end -- + + +def test_sqlalchemy_sorts_and_paginates_in_the_database(sqlalchemy_session): + """``totalResults`` counts every match, where a page holds only its slice.""" + total, page = query_users( + sqlalchemy_session, + SearchRequest[User](sort_by="userName", sort_order="descending", count=2), + ) + assert total == 5 + assert [record.user_name for record in page] == ["RSanchez", "mgarcia"] + + total, page = query_users( + sqlalchemy_session, + SearchRequest[User](sort_by="userName", start_index=3, count=2), + ) + assert total == 5 + assert [record.user_name for record in page] == ["jsmith", "mgarcia"] + + +SQLALCHEMY_SORTS = [ + "id", + "userName", + "displayName", + "title", + "active", + "meta.lastModified", +] + + +# -- sort-oracle-start -- +def test_sqlalchemy_orders_rows_the_way_the_helper_orders_resources(sqlalchemy_session): + """The ``ORDER BY`` and ``sort_resources`` answer a ``sortBy`` the same way. + + Feeding the helper resources already in primary key order gives its stable + sort the tie-break the query closes its own order with. + """ + stored = sqlalchemy_session.scalars(sqlalchemy.select(UserRecord)).all() + scim_users = sorted((to_scim_user(record) for record in stored), key=lambda u: u.id) + + for attribute in SQLALCHEMY_SORTS: + for order in SearchRequest.SortOrder: + request = SearchRequest[User](sort_by=attribute, sort_order=order) + _total, page = query_users(sqlalchemy_session, request) + ordered = sort_resources(scim_users, request.sort_by, order) + assert [record.id for record in page] == [user.id for user in ordered], ( + attribute, + order, + ) + + +# -- sort-oracle-end -- + + +def test_sqlalchemy_sorts_on_a_sub_attribute(sqlalchemy_session): + """A sub-attribute is stored under the attribute holding it, not under its own name.""" + _total, page = query_users( + sqlalchemy_session, SearchRequest[User](sort_by="meta.lastModified") + ) + assert [record.id for record in page] == ["2", "1", "4", "5", "3"] + + +def test_sqlalchemy_orders_an_unsorted_query_too(): + """Paging a query with no ``sortBy`` returns each resource exactly once. + + The rows are stored in an order of their own, which is the one an engine + left without an ``ORDER BY`` is free to return them in. + """ + session_factory = create_session_factory() + with session_factory() as session: + session.add_all(reversed(sqlalchemy_records())) + session.commit() + engine = session.get_bind() + + seen = [] + for start_index in (1, 3, 5): + _total, page = query_users( + session, SearchRequest[User](start_index=start_index, count=2) + ) + seen.extend(record.id for record in page) + + engine.dispose() + assert seen == ["1", "2", "3", "4", "5"] + + +def test_sqlalchemy_counts_the_filtered_results_only(sqlalchemy_session): + total, page = query_users( + sqlalchemy_session, SearchRequest[User](filter="active eq true", count=2) + ) + assert total == 4 + assert len(page) == 2 + + +def test_sqlalchemy_rejects_a_filter_on_an_unknown_attribute(): + """The request names the resource type it queries, so the filter is refused before any query.""" + with pytest.raises(ValidationError) as raised: + SearchRequest[User](filter='unknownAttr eq "x"') + assert raised.value.errors()[0]["type"] == "scim_invalidFilter" + + +@pytest.mark.parametrize("attribute", ["emails", "emails.value", "name"]) +def test_sqlalchemy_rejects_sorting_on_an_unreachable_attribute( + sqlalchemy_session, attribute +): + """An attribute spread over its own table, or holding none, has no column to sort on.""" + with pytest.raises(InvalidPathException): + query_users(sqlalchemy_session, SearchRequest[User](sort_by=attribute)) + + +def test_sqlalchemy_rejects_a_request_that_named_no_resource_type(sqlalchemy_session): + """The query orders by a resolved attribute, which an unparameterised request has none of.""" + with pytest.raises(InvalidPathException): + query_users(sqlalchemy_session, SearchRequest(sort_by="userName")) diff --git a/uv.lock b/uv.lock index 8d00734b..55cd8d77 100644 --- a/uv.lock +++ b/uv.lock @@ -640,6 +640,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1538,6 +1608,7 @@ dev = [ { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "sqlalchemy" }, { name = "starlette" }, { name = "tox-uv" }, ] @@ -1570,6 +1641,7 @@ dev = [ { name = "prek", specifier = ">=0.1.0" }, { name = "pytest", specifier = ">=8.2.1" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0" }, { name = "starlette", specifier = ">=1.2.0" }, { name = "tox-uv", specifier = ">=1.16.0" }, ] @@ -1809,6 +1881,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/d7/e0354e7334d33ea2795db3ecbe2977026c05a1ecf8ba4b5953c329872453/sqlalchemy-2.0.52-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7438774e1091192fc50a2bd8ceff5c596912d00ecd46587e88effdea7826101", size = 2172616, upload-time = "2026-08-11T20:58:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/5b/64/98eef682e6946eb1b4195a9a2393db4662ebfcc89f823ae78b938765c3c0/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c1b7ed45bf87b214e0a9def9c2313949067efe6269db5ef18d542ee13250af7", size = 3279798, upload-time = "2026-08-11T21:00:03.535Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/5b96afc1407c314347ad006b72bb251fb68ef84d05505ebf8a39bc47fcde/sqlalchemy-2.0.52-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:309cc8ba50fc5d2174189dfcd49cdf7aa711f8346afcff19f2642ae4fc449c14", size = 3277555, upload-time = "2026-08-11T21:05:47.932Z" }, + { url = "https://files.pythonhosted.org/packages/50/69/ce6776724511d1b5dd40477b08d6a5f0953a45375e092dfa852b1857732c/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2f9eccf8793c8c3f8dd2dfd11b9e400cb27d1d19370ef732b66017e212107822", size = 3231246, upload-time = "2026-08-11T21:00:05.184Z" }, + { url = "https://files.pythonhosted.org/packages/72/19/ab0cb9ccdafa2419c796ae62f8740aedb903f1e93bb326064b1a0147e458/sqlalchemy-2.0.52-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9255ceb65a80c1b001129060b63ee776a2e9c288be3b662be36dfbb888fffdcd", size = 3250763, upload-time = "2026-08-11T21:05:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/3c9b54b61bec4f493c0007dc9e2700c963c5b32667e21a506f1aca7a8115/sqlalchemy-2.0.52-cp310-cp310-win32.whl", hash = "sha256:2e15b1d1116a64fc399b8c2694a83f3e792fdc58df28514a81e1dc4f8cf22729", size = 2132169, upload-time = "2026-08-11T21:09:48.108Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a1/934bb6cf543a398c72784d1fc777eb530559c16ebe1549fa7611e5989ce9/sqlalchemy-2.0.52-cp310-cp310-win_amd64.whl", hash = "sha256:11560064cc4696e772298b6221ede59e646386d9f2a85d549365473b972f7850", size = 2156289, upload-time = "2026-08-11T21:09:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, + { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, + { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, + { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, + { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, + { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, + { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, +] + [[package]] name = "sqlparse" version = "0.6.0" From 920d674330c3b89b46ac70ca3b2d8c009a0fcb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 16:29:19 +0200 Subject: [PATCH 08/14] refactor: resolve instance paths through the attribute resolver Reading, writing and removing through a path resolved it against the type of the resource by splitting the string again: a schema URN matched by prefix, the segments looked up one by one. Resolving against the bound model had moved to the attribute resolver, and so had value selections, so the same question was answered three ways. The walk over an instance now takes the AttrPath the path designates to the resolver, and lands on the objects a ResolvedAttribute names; the bare schema URN is recognised by one function the bound and the instance side share. The depth the string walk allowed is one the grammar refuses at construction, so nothing reachable is lost. --- scim2_models/path.py | 295 +++++++++++++------------------------ scim2_models/resolution.py | 30 ++++ 2 files changed, 133 insertions(+), 192 deletions(-) diff --git a/scim2_models/path.py b/scim2_models/path.py index fd9e1336..a507894f 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -1,4 +1,3 @@ -from collections.abc import Iterable from collections.abc import Iterator from dataclasses import replace from inspect import isclass @@ -43,31 +42,15 @@ from .filters.visitor import Evaluator from .grammar import parse_path from .resolution import ResolvedAttribute +from .resolution import _target_model from .resolution import attribute_host +from .resolution import designated_model from .resolution import resolve_attr_path from .resolution import validate_value_selection ResourceT = TypeVar("ResourceT", bound="Resource[Any]") -def _is_in_schema(path_lower: str, schema: str) -> bool: - schema_lower = schema.lower() - return path_lower == schema_lower or path_lower.startswith(f"{schema_lower}:") - - -def _designating_schema(path_lower: str, schemas: Iterable[str]) -> str | None: - """Return the schema a qualified path is expressed in, if any. - - The longest match wins: the URN of an extension may extend the URN of the - resource it extends, and then both match while only the longer one - designates the model the attribute lives on. - """ - matching = [ - schema for schema in schemas if schema and _is_in_schema(path_lower, schema) - ] - return max(matching, key=len) if matching else None - - def _node_attr_path(node: PathNode) -> AttrPath: """Return the attribute path a parsed path node applies to.""" if isinstance(node, AttrPath): @@ -108,39 +91,15 @@ def _scim_name(model: type[BaseModel], field_name: str) -> str: return model.model_fields[field_name].serialization_alias or _to_camel(field_name) -def _resolve_field_names( - model: type[BaseModel], parts: list[str], path: str -) -> list[str]: - """Resolve each segment of a dotted path to the field name it designates. - - The segments are resolved against the declared types rather than against - the values a resource happens to carry, so a path naming an attribute the - model does not declare is reported the same way whether or not the - attributes leading to it are assigned. +class _Root(NamedTuple): + """The path designates the object itself rather than one of its attributes. - :raises PathNotFoundException: If a segment names an unknown field, or if a - segment other than the last one names an attribute with no - sub-attributes to descend into. + ``explicit`` tells a bare schema URN from the empty path: both land on the + object, but only the former spells it out, and a write of anything but a + mapping through it is a mistake to report rather than a no-op. """ - field_names: list[str] = [] - for part, next_part in zip(parts, parts[1:], strict=False): - field_name = _require_field(model, part, path) - field_type = model.get_field_root_type(field_name) - if not (isclass(field_type) and issubclass(field_type, BaseModel)): - raise PathNotFoundException(path=path, field=next_part) - field_names.append(field_name) - model = field_type - field_names.append(_require_field(model, parts[-1], path)) - return field_names - - -class _Resolution(NamedTuple): - """Result of instance path resolution.""" - - target: "BaseModel" - path_str: str - is_explicit_schema_path: bool = False + explicit: bool class _Target(NamedTuple): @@ -349,36 +308,20 @@ def value_filter(self) -> "FilterNode | None": def _designated_model(self) -> type[BaseModel] | None: """Return the model this path designates when it names no attribute. - The resource root and a bare schema URN both designate a model rather - than one of its attributes, and neither can go through attribute - resolution: the root names nothing, and nothing tells a schema URN from - a qualified path syntactically, so it is recognised by comparing the - whole path to the schemas the bound model knows. + The resource root designates the bound model, and a bare schema URN the + model it is the schema of, among the bound models and their extensions. + Neither can go through attribute resolution, since neither names an + attribute. """ - from .resources.resource import Extension - from .resources.resource import Resource - if not self.__scim_models__: return None if self.ast is None: return self.__scim_models__[0] - path = self.lower() for model in self.__scim_models__: - if not (isclass(model) and issubclass(model, Resource | Extension)): - continue - - if model.__schema__ and path == model.__schema__.lower(): - return model - - if not issubclass(model, Resource): - continue - - for schema, extension_model in model.get_extension_models().items(): - if path == schema.lower(): - return extension_model - + if (designated := designated_model(model, str(self))) is not None: + return designated return None def resolve(self) -> "ResolvedAttribute | None": @@ -511,95 +454,71 @@ def _resolve_head(self) -> "tuple[type[BaseModel], str] | None": return None return resolved.model, resolved.field_name - def _resolve_instance( + def _walk( self, resource: BaseModel, *, create: bool = False - ) -> _Resolution | None: - """Resolve the target object and remaining path. - - :param resource: The resource to resolve against. - :param create: If True, create extension instance if it doesn't exist. - :returns: Resolution with target object and path, or None if target doesn't exist. - :raises InvalidPathException: If the path references an unknown extension. + ) -> "_Root | _Target | None": + """Locate the objects holding the attribute this path designates. + + The path is resolved against the type of the resource rather than the + model it is bound to, so an unbound path reads and writes like a bound + one, and a path bound to a union answers for the type it is applied to. + A path crossing a multi-valued attribute fans out over its entries, as + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` has an unfiltered path + designate every one of them. + + :param resource: The object to walk from. + :param create: Whether an unassigned extension or complex attribute is + instantiated rather than ending the walk. Nothing is created for a + path that resolves to nothing. + :returns: The root when the path designates the resource itself, the + target otherwise, or :data:`None` when nothing is left to walk to. + :raises InvalidPathException: If the path is qualified by a URN that + designates neither the resource nor one of its extensions. + :raises PathNotFoundException: If the path names an attribute the model + does not declare, or a sub-attribute of one that has none. """ - from .resources.resource import Extension from .resources.resource import Resource - path_str = str(self) + if self.ast is None: + return _Root(explicit=False) - if ":" not in path_str: - return _Resolution(resource, path_str) + model = type(resource) + if (designated := designated_model(model, str(self))) is not None: + if isinstance(resource, designated): + return _Root(explicit=True) + return _Target([resource], designated.__name__, False) - path_lower = path_str.lower() - model_schema = "" - if isinstance(resource, Resource | Extension): - model_schema = getattr(type(resource), "__schema__", "") or "" - extension_models = ( - resource.get_extension_models() if isinstance(resource, Resource) else {} - ) + attr_path = self._designated_attr_path() + assert attr_path is not None - schema = _designating_schema(path_lower, [model_schema, *extension_models]) - if schema is None: + # A URN that designates no model the resource declares is refused on a + # resource, and reaches nothing on an object handled on its own. + if attr_path.uri and _target_model(model, attr_path, strict=False) is None: if isinstance(resource, Resource): - raise InvalidPathException(path=path_str) + raise InvalidPathException(path=str(self)) return None - sub_path = path_str[len(schema) :].lstrip(":") - ext_model = extension_models.get(schema) + resolved = resolve_attr_path(model, attr_path, strict=True) + assert resolved is not None - if ext_model is None: - return _Resolution(resource, sub_path, path_lower == schema.lower()) + host = attribute_host(resource, resolved) + if host is None: + if not create: + return None + host = resolved.model() + setattr(resource, resolved.model.__name__, host) - if path_lower == schema.lower(): - return _Resolution(resource, ext_model.__name__) + if resolved.sub_field_name is None: + return _Target([host], resolved.field_name, False) - ext_obj = getattr(resource, ext_model.__name__) - if create and ext_obj is None: - # Checked on the declared type before anything is instantiated, so - # that a refused write leaves no half-built extension behind. - _resolve_field_names(ext_model, sub_path.split("."), path_str) - ext_obj = ext_model() - setattr(resource, ext_model.__name__, ext_obj) - if ext_obj is None: + head = getattr(host, resolved.field_name) + if head is None and create: + head = self._create_intermediate(host, resolved.field_name) + if head is None: return None - return _Resolution(ext_obj, sub_path) - - def _walk_to_target( - self, obj: BaseModel, path_str: str, *, create: bool = False - ) -> "_Target | None": - """Navigate to the objects holding the field this path designates. - - A multi-valued attribute crossed on the way fans the walk out over its - entries, as :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` has an unfiltered - path designate every one of them. - - :param obj: The object to walk from. - :param path_str: The dotted path to walk. - :param create: Whether an unassigned complex attribute is instantiated - rather than ending the walk. - :returns: The target, or None when nothing is left to walk to. - :raises PathNotFoundException: If a segment names an attribute the model - does not declare, or one with no sub-attributes to descend into. - """ - field_names = _resolve_field_names(type(obj), path_str.split("."), str(self)) - hosts = [obj] - multivalued = False - - for field_name in field_names[:-1]: - entered: list[BaseModel] = [] - for host in hosts: - value = getattr(host, field_name) - if value is None and create: - value = self._create_intermediate(host, field_name) - if isinstance(value, list): - multivalued = True - entered.extend(value) - elif value is not None: - entered.append(value) - if not entered: - return None - hosts = entered - - return _Target(hosts, field_names[-1], multivalued) + if isinstance(head, list): + return _Target(list(head), resolved.sub_field_name, True) if head else None + return _Target([head], resolved.sub_field_name, False) @staticmethod def _create_intermediate(host: BaseModel, field_name: str) -> BaseModel | None: @@ -699,16 +618,11 @@ def _get(self, resource: ResourceT) -> Any: ] return values or None - if (resolution := self._resolve_instance(resource)) is None: - return None - - if not resolution.path_str: - return resolution.target - - if ( - target := self._walk_to_target(resolution.target, resolution.path_str) - ) is None: + target = self._walk(resource) + if target is None: return None + if isinstance(target, _Root): + return resource values = [getattr(host, target.field_name) for host in target.hosts] return values if target.multivalued else values[0] @@ -836,36 +750,11 @@ def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool if (selection := self._select(resource)) is not None: return self._set_selected(selection, value, is_add=is_add) - if (resolution := self._resolve_instance(resource, create=True)) is None: - return False - - obj = resolution.target - path_str = resolution.path_str - is_explicit_schema_path = resolution.is_explicit_schema_path - - if not path_str: - if not isinstance(value, dict): - if is_explicit_schema_path: - raise InvalidPathException(path=str(self)) - return False - filtered_value = { - k: v - for k, v in value.items() - if _find_field_name(type(obj), k) is not None - } - if not filtered_value: - return False - old_data = obj.model_dump() - updated_data = {**old_data, **filtered_value} - if updated_data == old_data: - return False - updated_obj = type(obj).model_validate(updated_data) - obj.__dict__.update(updated_obj.__dict__) - obj.__pydantic_fields_set__.update(updated_obj.__pydantic_fields_set__) - return True - - if (target := self._walk_to_target(obj, path_str, create=True)) is None: + target = self._walk(resource, create=True) + if target is None: return False + if isinstance(target, _Root): + return self._merge(resource, value, explicit=target.explicit) changed = [ self._set_field_value(host, target.field_name, value, is_add) @@ -873,6 +762,33 @@ def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool ] return any(changed) + def _merge(self, obj: BaseModel, value: Any, *, explicit: bool) -> bool: + """Write the attributes a mapping names onto the object the path designates. + + :param explicit: Whether the object was designated by its schema URN, + in which case a value that is not a mapping is reported rather + than ignored. + :raises InvalidPathException: If ``explicit`` and the value is not a + mapping. + """ + if not isinstance(value, dict): + if explicit: + raise InvalidPathException(path=str(self)) + return False + filtered_value = { + k: v for k, v in value.items() if _find_field_name(type(obj), k) is not None + } + if not filtered_value: + return False + old_data = obj.model_dump() + updated_data = {**old_data, **filtered_value} + if updated_data == old_data: + return False + updated_obj = type(obj).model_validate(updated_data) + obj.__dict__.update(updated_obj.__dict__) + obj.__pydantic_fields_set__.update(updated_obj.__pydantic_fields_set__) + return True + def set( self, resource: ResourceT, @@ -942,17 +858,12 @@ def _delete(self, resource: ResourceT, value: Any | None = None) -> bool: if (selection := self._select(resource)) is not None: return self._delete_selected(selection) - if (resolution := self._resolve_instance(resource)) is None: + target = self._walk(resource) + if target is None: return False - - if not resolution.path_str: + if isinstance(target, _Root): raise InvalidPathException(path=str(self)) - if ( - target := self._walk_to_target(resolution.target, resolution.path_str) - ) is None: - return False - changed = [ self._delete_field_value(host, target.field_name, value) for host in target.hosts diff --git a/scim2_models/resolution.py b/scim2_models/resolution.py index 7aaa9ef3..cfcb7dfa 100644 --- a/scim2_models/resolution.py +++ b/scim2_models/resolution.py @@ -146,6 +146,36 @@ def _extension_models(model: type[BaseModel]) -> dict[str, type[BaseModel]]: return dict(model.get_extension_models()) +def designated_model(model: type[BaseModel], path: str) -> type[BaseModel] | None: + """Return the model a path designates when it is a bare schema URN. + + Nothing tells a schema URN from a qualified path syntactically: + ``urn:ietf:params:scim:schemas:core:2.0:User`` reads as the attribute + ``User`` of the schema ``urn:ietf:params:scim:schemas:core:2.0``. So the + whole path is compared to the schemas the model knows, its own and those + of its extensions. + + :param model: The resource or extension model to compare against. + :param path: The whole path, as written. + :returns: The model the path is the schema of, or :data:`None` when it is + not one. + """ + from .resources.resource import Extension + from .resources.resource import Resource + + if not (isclass(model) and issubclass(model, Resource | Extension)): + return None + + path = path.lower() + if model.__schema__ and path == model.__schema__.lower(): + return model + + for schema, extension_model in _extension_models(model).items(): + if path == schema.lower(): + return extension_model + return None + + def _target_model( model: type[BaseModel], attr_path: AttrPath, *, strict: bool ) -> type[BaseModel] | None: From 0c8de7c61906573efab7a3a561a568dfe00413e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 16:30:45 +0200 Subject: [PATCH 09/14] refactor: let PatchOp resolve the attribute an operation applies to The constraint checks of a PATCH went through a private helper of Path that resolved the path and kept only the model and the head attribute, next to a local copy of the function answering the object an attribute lives on. Both are what Path.resolve and attribute_host already answer, so PatchOp calls them and Path carries nothing that only PatchOp reads. --- scim2_models/messages/patch_op.py | 36 ++++++++++++------------------- scim2_models/path.py | 13 ----------- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index 9676cac0..83771594 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -21,7 +21,7 @@ from ..exceptions import MutabilityException from ..exceptions import NoTargetException from ..path import Path -from ..resources.resource import Extension +from ..resolution import attribute_host from ..resources.resource import Resource from ..urn import URN from ..utils import _find_field_name @@ -65,18 +65,6 @@ def _names_a_declared_target( ) -def _attribute_host(resource: Resource[Any], holder: type[BaseModel]) -> Any: - """Return the object an attribute lives on. - - An attribute qualified by an extension URN is declared by the extension and - lives on the instance hanging off the resource under its class name, which - is unset until the extension carries a value. - """ - if not (isclass(holder) and issubclass(holder, Extension)): - return resource - return getattr(resource, holder.__name__, None) - - def _resolved_field(resource_class: type[BaseModel], attr_name: str) -> str | None: """Return the Python field a SCIM attribute name designates. @@ -332,12 +320,16 @@ def validate_operations(self, info: ValidationInfo) -> Self: # The attribute a qualified path applies to is declared by the # extension the URN designates, not by the resource, so the checks - # resolve the path instead of reading its first segment. - if (head := operation.path._resolve_head()) is None: + # resolve the path instead of reading its first segment. They read + # the attribute the path applies to rather than the sub-attribute it + # targets, as a constraint on a complex attribute governs everything + # written under it: "meta" is read-only where "meta.version" is not. + if (resolved := operation.path.resolve()) is None: continue - holder, field_name = head - operation._validate_mutability(holder, field_name) - operation._validate_required_attribute(holder, field_name, operation.value) + operation._validate_mutability(resolved.model, resolved.field_name) + operation._validate_required_attribute( + resolved.model, resolved.field_name, operation.value + ) return self @@ -407,15 +399,15 @@ def _check_immutable( field, or ``replace`` with the current value. """ assert operation.path is not None - if (head := operation.path._resolve_head()) is None: + if (resolved := operation.path.resolve()) is None: return - resource_class, field_name = head + field_name = resolved.field_name - mutability = resource_class.get_field_annotation(field_name, Mutability) + mutability = resolved.model.get_field_annotation(field_name, Mutability) if mutability != Mutability.immutable: return - host = _attribute_host(resource, resource_class) + host = attribute_host(resource, resolved) current_value = getattr(host, field_name, None) if operation.op == PatchOperation.Op.add and current_value is None: diff --git a/scim2_models/path.py b/scim2_models/path.py index a507894f..66a8b9fb 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -441,19 +441,6 @@ def urn(self) -> str | None: resolved = self.resolve() return resolved.urn if resolved is not None else None - def _resolve_head(self) -> "tuple[type[BaseModel], str] | None": - """Resolve the attribute the path applies to, and the model declaring it. - - A constraint on a complex attribute governs everything written under it, - so it is the attribute a path applies to that answers for an operation - rather than the sub-attribute it targets: ``meta`` is read-only where the - ``meta.version`` it holds is not. - """ - resolved = self.resolve() - if resolved is None: - return None - return resolved.model, resolved.field_name - def _walk( self, resource: BaseModel, *, create: bool = False ) -> "_Root | _Target | None": From 443ad9ad1216d7d9183acb9e2060150159d14306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 16:34:13 +0200 Subject: [PATCH 10/14] refactor: move instance access out of path.py Path carried two halves that shared nothing but the parsed form: what a path designates on a model, and how it reads and writes an instance. The second now lives in its own module, as functions taking the path they act for, and Path keeps its declarative surface next to the three methods that delegate to them. --- scim2_models/access.py | 446 +++++++++++++++++++++++++++++++++++++++++ scim2_models/path.py | 434 +-------------------------------------- 2 files changed, 452 insertions(+), 428 deletions(-) create mode 100644 scim2_models/access.py diff --git a/scim2_models/access.py b/scim2_models/access.py new file mode 100644 index 00000000..0832cf79 --- /dev/null +++ b/scim2_models/access.py @@ -0,0 +1,446 @@ +from inspect import isclass +from typing import TYPE_CHECKING +from typing import Any +from typing import NamedTuple +from typing import cast +from typing import get_args +from typing import get_origin + +from .base import BaseModel +from .exceptions import InvalidPathException +from .exceptions import NoTargetException +from .exceptions import PathNotFoundException +from .filters.filter import validate_value_filter +from .filters.visitor import Evaluator +from .resolution import _target_model +from .resolution import attribute_host +from .resolution import designated_model +from .resolution import resolve_attr_path +from .resolution import validate_value_selection +from .utils import UNION_TYPES +from .utils import _find_field_name + +if TYPE_CHECKING: + from .path import Path + + +def _accepts_none(model: type[BaseModel], field_name: str) -> bool: + """Whether the annotation of a field allows :data:`None`.""" + annotation = model.model_fields[field_name].annotation + return get_origin(annotation) in UNION_TYPES and type(None) in get_args(annotation) + + +def _to_comparable(value: Any) -> Any: + """Convert a value to a comparable form (dict for BaseModel).""" + return value.model_dump() if isinstance(value, BaseModel) else value + + +def _values_match(value1: Any, value2: Any) -> bool: + """Check if two values match, handling BaseModel comparison.""" + return bool(_to_comparable(value1) == _to_comparable(value2)) + + +def _value_in_list(current_list: list[Any], new_value: Any) -> bool: + """Check if a value exists in a list, handling BaseModel comparison.""" + return any(_values_match(item, new_value) for item in current_list) + + +def _require_field(model: type[BaseModel], name: str, path: str) -> str: + """Return the field a path segment names, or report it as missing.""" + if (field_name := _find_field_name(model, name)) is None: + raise PathNotFoundException(path=path, field=name) + return field_name + + +class _Root(NamedTuple): + """The path designates the object itself rather than one of its attributes. + + ``explicit`` tells a bare schema URN from the empty path: both land on the + object, but only the former spells it out, and a write of anything but a + mapping through it is a mistake to report rather than a no-op. + """ + + explicit: bool + + +class _Target(NamedTuple): + """The objects a path lands on, and the field it designates on each. + + A path crossing a multi-valued attribute designates one field per entry, so + ``emails.value`` lands on every email rather than on a single object. + """ + + hosts: list["BaseModel"] + field_name: str + multivalued: bool + + +class _Selection(NamedTuple): + """The entries a value-selecting path matched, and where they live. + + ``sub_attr`` is the attribute targeted past the brackets, which + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows a PATCH path to name. + """ + + host: Any + field_name: str + matched: list[Any] + sub_attr: str | None + + +def _walk( + path: "Path[Any]", resource: BaseModel, *, create: bool = False +) -> "_Root | _Target | None": + """Locate the objects holding the attribute a path designates. + + The path is resolved against the type of the resource rather than the + model it is bound to, so an unbound path reads and writes like a bound + one, and a path bound to a union answers for the type it is applied to. + A path crossing a multi-valued attribute fans out over its entries, as + :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` has an unfiltered path + designate every one of them. + + :param resource: The object to walk from. + :param create: Whether an unassigned extension or complex attribute is + instantiated rather than ending the walk. Nothing is created for a + path that resolves to nothing. + :returns: The root when the path designates the resource itself, the + target otherwise, or :data:`None` when nothing is left to walk to. + :raises InvalidPathException: If the path is qualified by a URN that + designates neither the resource nor one of its extensions. + :raises PathNotFoundException: If the path names an attribute the model + does not declare, or a sub-attribute of one that has none. + """ + from .resources.resource import Resource + + if path.ast is None: + return _Root(explicit=False) + + model = type(resource) + if (designated := designated_model(model, str(path))) is not None: + if isinstance(resource, designated): + return _Root(explicit=True) + return _Target([resource], designated.__name__, False) + + attr_path = path._designated_attr_path() + assert attr_path is not None + + # A URN that designates no model the resource declares is refused on a + # resource, and reaches nothing on an object handled on its own. + if attr_path.uri and _target_model(model, attr_path, strict=False) is None: + if isinstance(resource, Resource): + raise InvalidPathException(path=str(path)) + return None + + resolved = resolve_attr_path(model, attr_path, strict=True) + assert resolved is not None + + host = attribute_host(resource, resolved) + if host is None: + if not create: + return None + host = resolved.model() + setattr(resource, resolved.model.__name__, host) + + if resolved.sub_field_name is None: + return _Target([host], resolved.field_name, False) + + head = getattr(host, resolved.field_name) + if head is None and create: + head = _create_intermediate(host, resolved.field_name) + if head is None: + return None + if isinstance(head, list): + return _Target(list(head), resolved.sub_field_name, True) if head else None + return _Target([head], resolved.sub_field_name, False) + + +def _create_intermediate(host: BaseModel, field_name: str) -> BaseModel | None: + """Instantiate an unassigned complex attribute so a value can be set under it. + + The walk has already established that the attribute is complex, so only + a multi-valued one is left alone: entries that do not exist have no + field to write to, and inventing one would guess what the caller meant + to address. + """ + if type(host).get_field_multiplicity(field_name): + return None + field_type = cast("type[BaseModel]", type(host).get_field_root_type(field_name)) + sub_obj = field_type() + setattr(host, field_name, sub_obj) + return sub_obj + + +def _select(path: "Path[Any]", resource: BaseModel) -> "_Selection | None": + """Resolve a value-selecting path against a resource. + + The filter between the brackets is evaluated strictly, so an attribute + the model does not declare is reported rather than silently matching + nothing. Tolerance belongs to :meth:`get`, :meth:`set` and + :meth:`delete`, which swallow the failure when asked to. + + :returns: The object holding the attribute, the Python field name, and + the matching entries, or :data:`None` if this is not a + value-selecting path. + :raises PathNotFoundException: If the selected attribute is unknown. + :raises InvalidFilterException: If the filter between the brackets + names an attribute the selected model does not declare. + """ + value_path = path._as_value_path() + if value_path is None: + return None + + model = type(resource) + resolved = resolve_attr_path(model, value_path.attr_path, strict=False) + if resolved is None: + raise PathNotFoundException(path=str(path), field=value_path.attr_path.attr) + + # Checked before reading the resource, so that a selection that cannot + # apply is rejected whether or not the attribute happens to be set. + validate_value_selection(resolved) + validate_value_filter(resolved, value_path.val_filter) + + host = attribute_host(resource, resolved) + if host is None: + return _Selection(None, resolved.field_name, [], value_path.sub_attr) + + matched = Evaluator(model, resource).select(value_path) + return _Selection(host, resolved.field_name, matched, value_path.sub_attr) + + +def get_value(path: "Path[Any]", resource: BaseModel) -> Any: + """Read the value a path designates on a resource.""" + if (selection := _select(path, resource)) is not None: + if selection.sub_attr is None: + return selection.matched or None + values = [ + getattr( + item, + _require_field(type(item), selection.sub_attr, str(path)), + None, + ) + for item in selection.matched + if isinstance(item, BaseModel) + ] + return values or None + + target = _walk(path, resource) + if target is None: + return None + if isinstance(target, _Root): + return resource + + values = [getattr(host, target.field_name) for host in target.hosts] + return values if target.multivalued else values[0] + + +def _set_selected( + path: "Path[Any]", selection: "_Selection", value: Any, *, is_add: bool = False +) -> bool: + """Apply a value to every entry matched by a value selection. + + :raises NoTargetException: If a replacement selection matches nothing, + per :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>`. That failure is + defined for ``replace`` only: :rfc:`§3.5.2.1 <7644#section-3.5.2.1>` + says nothing of a selection that matches nothing for ``add``, so + the operation is a no-op instead. `Errata 8097 + `_ asks for value + selections in ``add`` to be clarified at all, implementations + differing on whether they are allowed. + """ + host, field_name, matched, sub_attr = selection + + if not matched: + if is_add: + return False + raise NoTargetException( + detail=f"no value of '{field_name}' matches the path filter" + ) + + if sub_attr is None: + # Without a sub-attribute the matched entries are replaced wholesale. + current = getattr(host, field_name) + replacement = list(current) + item_type = type(host).get_field_root_type(field_name) + new_value = ( + item_type.model_validate(value) + if isinstance(value, dict) + and isclass(item_type) + and issubclass(item_type, BaseModel) + else value + ) + modified = False + for index, item in enumerate(replacement): + if any(item is candidate for candidate in matched): + if not _values_match(item, new_value): + replacement[index] = new_value + modified = True + if modified: + setattr(host, field_name, replacement) + return modified + + modified = False + for item in matched: + item_field = _require_field(type(item), sub_attr, str(path)) + if getattr(item, item_field) != value: + setattr(item, item_field, value) + modified = True + return modified + + +def _delete_selected(path: "Path[Any]", selection: "_Selection") -> bool: + """Remove every entry matched by a value selection. + + A selection that matches nothing leaves the resource untouched and + succeeds: :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` requires + ``noTarget`` only for a missing ``path``, and its removal example + states that "if the user was not a member of this group, no changes + should be made to the resource, and a success response should be + returned". + """ + host, field_name, matched, sub_attr = selection + + if not matched: + return False + + if sub_attr is not None: + modified = False + for item in matched: + item_field = _require_field(type(item), sub_attr, str(path)) + if getattr(item, item_field) is not None: + setattr(item, item_field, None) + modified = True + return modified + + remaining = [ + item + for item in getattr(host, field_name) + if not any(item is candidate for candidate in matched) + ] + # An attribute left without any value is unassigned, which RFC7643 §2.5 + # makes an empty list as much as null. "schemas" is the one annotated + # without None, so it is emptied where the others are unset. Refusing to + # unassign a required attribute belongs to PatchOp, which answers + # "mutability" rather than silently leaving a value behind. + if not remaining and _accepts_none(type(host), field_name): + setattr(host, field_name, None) + else: + setattr(host, field_name, remaining) + return True + + +def set_value( + path: "Path[Any]", resource: BaseModel, value: Any, *, is_add: bool = False +) -> bool: + """Write a value where a path designates on a resource.""" + if (selection := _select(path, resource)) is not None: + return _set_selected(path, selection, value, is_add=is_add) + + target = _walk(path, resource, create=True) + if target is None: + return False + if isinstance(target, _Root): + return _merge(path, resource, value, explicit=target.explicit) + + changed = [ + _set_field_value(host, target.field_name, value, is_add) + for host in target.hosts + ] + return any(changed) + + +def _merge(path: "Path[Any]", obj: BaseModel, value: Any, *, explicit: bool) -> bool: + """Write the attributes a mapping names onto the object the path designates. + + :param explicit: Whether the object was designated by its schema URN, + in which case a value that is not a mapping is reported rather + than ignored. + :raises InvalidPathException: If ``explicit`` and the value is not a + mapping. + """ + if not isinstance(value, dict): + if explicit: + raise InvalidPathException(path=str(path)) + return False + filtered_value = { + k: v for k, v in value.items() if _find_field_name(type(obj), k) is not None + } + if not filtered_value: + return False + old_data = obj.model_dump() + updated_data = {**old_data, **filtered_value} + if updated_data == old_data: + return False + updated_obj = type(obj).model_validate(updated_data) + obj.__dict__.update(updated_obj.__dict__) + obj.__pydantic_fields_set__.update(updated_obj.__pydantic_fields_set__) + return True + + +def _set_field_value(obj: BaseModel, field_name: str, value: Any, is_add: bool) -> bool: + """Set or add a value to a field.""" + is_multivalued = obj.get_field_multiplicity(field_name) + + if is_add and is_multivalued: + current_list = getattr(obj, field_name) or [] + if isinstance(value, list): + new_values = [v for v in value if not _value_in_list(current_list, v)] + if not new_values: + return False + setattr(obj, field_name, current_list + new_values) + else: + if _value_in_list(current_list, value): + return False + setattr(obj, field_name, [*current_list, value]) + return True + + if is_multivalued and not isinstance(value, list) and value is not None: + value = [value] + + old_value = getattr(obj, field_name) + if old_value == value: + return False + + setattr(obj, field_name, value) + return True + + +def delete_value( + path: "Path[Any]", resource: BaseModel, value: Any | None = None +) -> bool: + """Unassign what a path designates on a resource, or remove a value from it.""" + if (selection := _select(path, resource)) is not None: + return _delete_selected(path, selection) + + target = _walk(path, resource) + if target is None: + return False + if isinstance(target, _Root): + raise InvalidPathException(path=str(path)) + + changed = [ + _delete_field_value(host, target.field_name, value) for host in target.hosts + ] + return any(changed) + + +def _delete_field_value( + obj: BaseModel, field_name: str, value: Any | None = None +) -> bool: + """Unassign a field, or remove the matching entries of a multi-valued one.""" + if (current_value := getattr(obj, field_name)) is None: + return False + + if value is None: + setattr(obj, field_name, None) + return True + + if not isinstance(current_value, list): + return False + + new_list = [item for item in current_value if not _values_match(item, value)] + if len(new_list) == len(current_value): + return False + + setattr(obj, field_name, new_list or None) + return True diff --git a/scim2_models/path.py b/scim2_models/path.py index 66a8b9fb..66082a0c 100644 --- a/scim2_models/path.py +++ b/scim2_models/path.py @@ -4,17 +4,14 @@ from typing import TYPE_CHECKING from typing import Any from typing import Generic -from typing import NamedTuple from typing import TypeVar -from typing import cast -from typing import get_args -from typing import get_origin +from .access import delete_value +from .access import get_value +from .access import set_value from .base import BaseModel from .binding import _BoundToModels from .urn import URN -from .utils import UNION_TYPES -from .utils import _find_field_name from .utils import _to_camel if TYPE_CHECKING: @@ -28,7 +25,6 @@ from .exceptions import InvalidFilterException from .exceptions import InvalidPathException from .exceptions import NoTargetException -from .exceptions import PathNotFoundException from .expressions import AttrPath from .expressions import Comparison from .expressions import FilterNode @@ -38,15 +34,10 @@ from .expressions import ValuePath from .expressions import _Expression from .expressions import _text -from .filters.filter import validate_value_filter -from .filters.visitor import Evaluator from .grammar import parse_path from .resolution import ResolvedAttribute -from .resolution import _target_model -from .resolution import attribute_host from .resolution import designated_model from .resolution import resolve_attr_path -from .resolution import validate_value_selection ResourceT = TypeVar("ResourceT", bound="Resource[Any]") @@ -58,75 +49,11 @@ def _node_attr_path(node: PathNode) -> AttrPath: return node.attr_path -def _accepts_none(model: type[BaseModel], field_name: str) -> bool: - """Whether the annotation of a field allows :data:`None`.""" - annotation = model.model_fields[field_name].annotation - return get_origin(annotation) in UNION_TYPES and type(None) in get_args(annotation) - - -def _to_comparable(value: Any) -> Any: - """Convert a value to a comparable form (dict for BaseModel).""" - return value.model_dump() if isinstance(value, BaseModel) else value - - -def _values_match(value1: Any, value2: Any) -> bool: - """Check if two values match, handling BaseModel comparison.""" - return bool(_to_comparable(value1) == _to_comparable(value2)) - - -def _value_in_list(current_list: list[Any], new_value: Any) -> bool: - """Check if a value exists in a list, handling BaseModel comparison.""" - return any(_values_match(item, new_value) for item in current_list) - - -def _require_field(model: type[BaseModel], name: str, path: str) -> str: - """Return the field a path segment names, or report it as missing.""" - if (field_name := _find_field_name(model, name)) is None: - raise PathNotFoundException(path=path, field=name) - return field_name - - def _scim_name(model: type[BaseModel], field_name: str) -> str: """Return the name a field is serialized under, ``$ref`` included.""" return model.model_fields[field_name].serialization_alias or _to_camel(field_name) -class _Root(NamedTuple): - """The path designates the object itself rather than one of its attributes. - - ``explicit`` tells a bare schema URN from the empty path: both land on the - object, but only the former spells it out, and a write of anything but a - mapping through it is a mistake to report rather than a no-op. - """ - - explicit: bool - - -class _Target(NamedTuple): - """The objects a path lands on, and the field it designates on each. - - A path crossing a multi-valued attribute designates one field per entry, so - ``emails.value`` lands on every email rather than on a single object. - """ - - hosts: list["BaseModel"] - field_name: str - multivalued: bool - - -class _Selection(NamedTuple): - """The entries a value-selecting path matched, and where they live. - - ``sub_attr`` is the attribute targeted past the brackets, which - :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` allows a PATCH path to name. - """ - - host: Any - field_name: str - matched: list[Any] - sub_attr: str | None - - class Path(_BoundToModels, _Expression, Generic[ResourceT]): _ast: "PathNode | None" = None """The parsed path, kept once :attr:`ast` has computed it.""" @@ -441,88 +368,6 @@ def urn(self) -> str | None: resolved = self.resolve() return resolved.urn if resolved is not None else None - def _walk( - self, resource: BaseModel, *, create: bool = False - ) -> "_Root | _Target | None": - """Locate the objects holding the attribute this path designates. - - The path is resolved against the type of the resource rather than the - model it is bound to, so an unbound path reads and writes like a bound - one, and a path bound to a union answers for the type it is applied to. - A path crossing a multi-valued attribute fans out over its entries, as - :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` has an unfiltered path - designate every one of them. - - :param resource: The object to walk from. - :param create: Whether an unassigned extension or complex attribute is - instantiated rather than ending the walk. Nothing is created for a - path that resolves to nothing. - :returns: The root when the path designates the resource itself, the - target otherwise, or :data:`None` when nothing is left to walk to. - :raises InvalidPathException: If the path is qualified by a URN that - designates neither the resource nor one of its extensions. - :raises PathNotFoundException: If the path names an attribute the model - does not declare, or a sub-attribute of one that has none. - """ - from .resources.resource import Resource - - if self.ast is None: - return _Root(explicit=False) - - model = type(resource) - if (designated := designated_model(model, str(self))) is not None: - if isinstance(resource, designated): - return _Root(explicit=True) - return _Target([resource], designated.__name__, False) - - attr_path = self._designated_attr_path() - assert attr_path is not None - - # A URN that designates no model the resource declares is refused on a - # resource, and reaches nothing on an object handled on its own. - if attr_path.uri and _target_model(model, attr_path, strict=False) is None: - if isinstance(resource, Resource): - raise InvalidPathException(path=str(self)) - return None - - resolved = resolve_attr_path(model, attr_path, strict=True) - assert resolved is not None - - host = attribute_host(resource, resolved) - if host is None: - if not create: - return None - host = resolved.model() - setattr(resource, resolved.model.__name__, host) - - if resolved.sub_field_name is None: - return _Target([host], resolved.field_name, False) - - head = getattr(host, resolved.field_name) - if head is None and create: - head = self._create_intermediate(host, resolved.field_name) - if head is None: - return None - if isinstance(head, list): - return _Target(list(head), resolved.sub_field_name, True) if head else None - return _Target([head], resolved.sub_field_name, False) - - @staticmethod - def _create_intermediate(host: BaseModel, field_name: str) -> BaseModel | None: - """Instantiate an unassigned complex attribute so a value can be set under it. - - The walk has already established that the attribute is complex, so only - a multi-valued one is left alone: entries that do not exist have no - field to write to, and inventing one would guess what the caller meant - to address. - """ - if type(host).get_field_multiplicity(field_name): - return None - field_type = cast("type[BaseModel]", type(host).get_field_root_type(field_name)) - sub_obj = field_type() - setattr(host, field_name, sub_obj) - return sub_obj - def _as_value_path(self) -> ValuePath | None: """Normalise a value-selecting path into a single representation. @@ -553,67 +398,6 @@ def _as_value_path(self) -> ValuePath | None: ) return ValuePath(attr_path=head, val_filter=inner) - def _select(self, resource: BaseModel) -> "_Selection | None": - """Resolve a value-selecting path against a resource. - - The filter between the brackets is evaluated strictly, so an attribute - the model does not declare is reported rather than silently matching - nothing. Tolerance belongs to :meth:`get`, :meth:`set` and - :meth:`delete`, which swallow the failure when asked to. - - :returns: The object holding the attribute, the Python field name, and - the matching entries, or :data:`None` if this is not a - value-selecting path. - :raises PathNotFoundException: If the selected attribute is unknown. - :raises InvalidFilterException: If the filter between the brackets - names an attribute the selected model does not declare. - """ - value_path = self._as_value_path() - if value_path is None: - return None - - model = type(resource) - resolved = resolve_attr_path(model, value_path.attr_path, strict=False) - if resolved is None: - raise PathNotFoundException(path=str(self), field=value_path.attr_path.attr) - - # Checked before reading the resource, so that a selection that cannot - # apply is rejected whether or not the attribute happens to be set. - validate_value_selection(resolved) - validate_value_filter(resolved, value_path.val_filter) - - host = attribute_host(resource, resolved) - if host is None: - return _Selection(None, resolved.field_name, [], value_path.sub_attr) - - matched = Evaluator(model, resource).select(value_path) - return _Selection(host, resolved.field_name, matched, value_path.sub_attr) - - def _get(self, resource: ResourceT) -> Any: - """Get the value at this path from a resource.""" - if (selection := self._select(resource)) is not None: - if selection.sub_attr is None: - return selection.matched or None - values = [ - getattr( - item, - _require_field(type(item), selection.sub_attr, str(self)), - None, - ) - for item in selection.matched - if isinstance(item, BaseModel) - ] - return values or None - - target = self._walk(resource) - if target is None: - return None - if isinstance(target, _Root): - return resource - - values = [getattr(host, target.field_name) for host in target.hosts] - return values if target.multivalued else values[0] - def get(self, resource: ResourceT, *, strict: bool = True) -> Any: """Get the value at this path from a resource. @@ -633,149 +417,12 @@ def get(self, resource: ResourceT, *, strict: bool = True) -> Any: apply to the attribute it selects from. """ try: - return self._get(resource) + return get_value(self, resource) except (InvalidPathException, InvalidFilterException): if strict: raise return None - def _set_selected( - self, selection: "_Selection", value: Any, *, is_add: bool = False - ) -> bool: - """Apply a value to every entry matched by a value selection. - - :raises NoTargetException: If a replacement selection matches nothing, - per :rfc:`RFC7644 §3.5.2.3 <7644#section-3.5.2.3>`. That failure is - defined for ``replace`` only: :rfc:`§3.5.2.1 <7644#section-3.5.2.1>` - says nothing of a selection that matches nothing for ``add``, so - the operation is a no-op instead. `Errata 8097 - `_ asks for value - selections in ``add`` to be clarified at all, implementations - differing on whether they are allowed. - """ - host, field_name, matched, sub_attr = selection - - if not matched: - if is_add: - return False - raise NoTargetException( - detail=f"no value of '{field_name}' matches the path filter" - ) - - if sub_attr is None: - # Without a sub-attribute the matched entries are replaced wholesale. - current = getattr(host, field_name) - replacement = list(current) - item_type = type(host).get_field_root_type(field_name) - new_value = ( - item_type.model_validate(value) - if isinstance(value, dict) - and isclass(item_type) - and issubclass(item_type, BaseModel) - else value - ) - modified = False - for index, item in enumerate(replacement): - if any(item is candidate for candidate in matched): - if not _values_match(item, new_value): - replacement[index] = new_value - modified = True - if modified: - setattr(host, field_name, replacement) - return modified - - modified = False - for item in matched: - item_field = _require_field(type(item), sub_attr, str(self)) - if getattr(item, item_field) != value: - setattr(item, item_field, value) - modified = True - return modified - - def _delete_selected(self, selection: "_Selection") -> bool: - """Remove every entry matched by a value selection. - - A selection that matches nothing leaves the resource untouched and - succeeds: :rfc:`RFC7644 §3.5.2.2 <7644#section-3.5.2.2>` requires - ``noTarget`` only for a missing ``path``, and its removal example - states that "if the user was not a member of this group, no changes - should be made to the resource, and a success response should be - returned". - """ - host, field_name, matched, sub_attr = selection - - if not matched: - return False - - if sub_attr is not None: - modified = False - for item in matched: - item_field = _require_field(type(item), sub_attr, str(self)) - if getattr(item, item_field) is not None: - setattr(item, item_field, None) - modified = True - return modified - - remaining = [ - item - for item in getattr(host, field_name) - if not any(item is candidate for candidate in matched) - ] - # An attribute left without any value is unassigned, which RFC7643 §2.5 - # makes an empty list as much as null. "schemas" is the one annotated - # without None, so it is emptied where the others are unset. Refusing to - # unassign a required attribute belongs to PatchOp, which answers - # "mutability" rather than silently leaving a value behind. - if not remaining and _accepts_none(type(host), field_name): - setattr(host, field_name, None) - else: - setattr(host, field_name, remaining) - return True - - def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool: - """Set a value at this path on a resource.""" - if (selection := self._select(resource)) is not None: - return self._set_selected(selection, value, is_add=is_add) - - target = self._walk(resource, create=True) - if target is None: - return False - if isinstance(target, _Root): - return self._merge(resource, value, explicit=target.explicit) - - changed = [ - self._set_field_value(host, target.field_name, value, is_add) - for host in target.hosts - ] - return any(changed) - - def _merge(self, obj: BaseModel, value: Any, *, explicit: bool) -> bool: - """Write the attributes a mapping names onto the object the path designates. - - :param explicit: Whether the object was designated by its schema URN, - in which case a value that is not a mapping is reported rather - than ignored. - :raises InvalidPathException: If ``explicit`` and the value is not a - mapping. - """ - if not isinstance(value, dict): - if explicit: - raise InvalidPathException(path=str(self)) - return False - filtered_value = { - k: v for k, v in value.items() if _find_field_name(type(obj), k) is not None - } - if not filtered_value: - return False - old_data = obj.model_dump() - updated_data = {**old_data, **filtered_value} - if updated_data == old_data: - return False - updated_obj = type(obj).model_validate(updated_data) - obj.__dict__.update(updated_obj.__dict__) - obj.__pydantic_fields_set__.update(updated_obj.__pydantic_fields_set__) - return True - def set( self, resource: ResourceT, @@ -804,81 +451,12 @@ def set( selection matches nothing. """ try: - return self._set(resource, value, is_add=is_add) + return set_value(self, resource, value, is_add=is_add) except (InvalidPathException, InvalidFilterException, NoTargetException): if strict: raise return False - @staticmethod - def _set_field_value( - obj: BaseModel, field_name: str, value: Any, is_add: bool - ) -> bool: - """Set or add a value to a field.""" - is_multivalued = obj.get_field_multiplicity(field_name) - - if is_add and is_multivalued: - current_list = getattr(obj, field_name) or [] - if isinstance(value, list): - new_values = [v for v in value if not _value_in_list(current_list, v)] - if not new_values: - return False - setattr(obj, field_name, current_list + new_values) - else: - if _value_in_list(current_list, value): - return False - setattr(obj, field_name, [*current_list, value]) - return True - - if is_multivalued and not isinstance(value, list) and value is not None: - value = [value] - - old_value = getattr(obj, field_name) - if old_value == value: - return False - - setattr(obj, field_name, value) - return True - - def _delete(self, resource: ResourceT, value: Any | None = None) -> bool: - """Delete a value at this path from a resource.""" - if (selection := self._select(resource)) is not None: - return self._delete_selected(selection) - - target = self._walk(resource) - if target is None: - return False - if isinstance(target, _Root): - raise InvalidPathException(path=str(self)) - - changed = [ - self._delete_field_value(host, target.field_name, value) - for host in target.hosts - ] - return any(changed) - - @staticmethod - def _delete_field_value( - obj: BaseModel, field_name: str, value: Any | None = None - ) -> bool: - """Unassign a field, or remove the matching entries of a multi-valued one.""" - if (current_value := getattr(obj, field_name)) is None: - return False - - if value is None: - setattr(obj, field_name, None) - return True - - if not isinstance(current_value, list): - return False - - new_list = [item for item in current_value if not _values_match(item, value)] - if len(new_list) == len(current_value): - return False - - setattr(obj, field_name, new_list or None) - return True - def delete( self, resource: ResourceT, value: Any | None = None, *, strict: bool = True ) -> bool: @@ -900,7 +478,7 @@ def delete( apply to the attribute it selects from. """ try: - return self._delete(resource, value) + return delete_value(self, resource, value) except (InvalidPathException, InvalidFilterException): if strict: raise From ef6568e693decf5ba07d001e3ae169ef96b7130e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 17:05:49 +0200 Subject: [PATCH 11/14] refactor: gather filters and paths under scim2_models.path Filters and paths share one grammar with two start rules, one syntax tree, one resolver and one evaluator, yet the modules answering for them were split between a filters package and five modules at the root of the distribution, with the public namespace re-exporting mostly what lived outside it. Every filter is built on attribute paths where a path may carry no filter, so the attribute path is the notion the whole toolkit rests on, and the package is named for it. scim2_models.filters is gone; from scim2_models.path import Path keeps working. --- doc/filters.rst | 24 ++++---- doc/guides/_examples/sqlalchemy_example.py | 12 ++-- doc/reference.rst | 12 ++-- scim2_models/__init__.py | 4 +- scim2_models/filters/__init__.py | 55 ------------------ scim2_models/messages/patch_op.py | 2 +- scim2_models/messages/response_parameters.py | 2 +- scim2_models/messages/search_request.py | 4 +- scim2_models/path/__init__.py | 59 ++++++++++++++++++++ scim2_models/{ => path}/access.py | 18 +++--- scim2_models/{ => path}/binding.py | 6 +- scim2_models/{ => path}/expressions.py | 2 +- scim2_models/{filters => path}/filter.py | 44 +++++++-------- scim2_models/{ => path}/grammar.py | 10 ++-- scim2_models/{ => path}/path.py | 42 +++++++------- scim2_models/{ => path}/resolution.py | 24 ++++---- scim2_models/{filters => path}/visitor.py | 30 +++++----- tests/test_dynamic_resources.py | 2 +- tests/test_filter_grammar.py | 24 ++++---- tests/test_filter_semantics.py | 32 +++++------ tests/test_template_strings.py | 4 +- 21 files changed, 208 insertions(+), 204 deletions(-) delete mode 100644 scim2_models/filters/__init__.py create mode 100644 scim2_models/path/__init__.py rename scim2_models/{ => path}/access.py (97%) rename scim2_models/{ => path}/binding.py (97%) rename scim2_models/{ => path}/expressions.py (99%) rename scim2_models/{filters => path}/filter.py (94%) rename scim2_models/{ => path}/grammar.py (97%) rename scim2_models/{ => path}/path.py (96%) rename scim2_models/{ => path}/resolution.py (97%) rename scim2_models/{filters => path}/visitor.py (95%) diff --git a/doc/filters.rst b/doc/filters.rst index 990f90ab..b382da36 100644 --- a/doc/filters.rst +++ b/doc/filters.rst @@ -77,12 +77,12 @@ serves several resource types must evaluate it to false rather than fail You can also build a filter without writing any SCIM syntax. Expression nodes combine with the Python boolean operators and render back to valid syntax, parentheses included. A -:class:`~scim2_models.filters.Comparison` quotes its value the same way, and an -:class:`~scim2_models.filters.AttrPath` refuses a name the grammar would not read as one: +:class:`~scim2_models.path.Comparison` quotes its value the same way, and an +:class:`~scim2_models.path.AttrPath` refuses a name the grammar would not read as one: .. doctest:: - >>> from scim2_models.filters import AttrPath, Comparison, CompareOperator, Present + >>> from scim2_models.path import AttrPath, Comparison, CompareOperator, Present >>> work = Comparison(AttrPath("userName"), CompareOperator.eq, "bjensen") >>> titled = Present(AttrPath("title")) @@ -112,9 +112,9 @@ scim2-models ships no SQL, but it gives you the parts you need to write that que :class:`~scim2_models.ScimFilter` parses a filter when it is created, and keeps the result in :attr:`ScimFilter.ast `: an abstract syntax tree, that is, a set of nodes referencing each other. ``userName eq "bjensen" and title pr`` is a -:class:`~scim2_models.filters.LogicalExpr` node with two terms, a -:class:`~scim2_models.filters.Comparison` and a :class:`~scim2_models.filters.Present`, each holding -the :class:`~scim2_models.filters.AttrPath` it applies to: +:class:`~scim2_models.path.LogicalExpr` node with two terms, a +:class:`~scim2_models.path.Comparison` and a :class:`~scim2_models.path.Present`, each holding +the :class:`~scim2_models.path.AttrPath` it applies to: .. doctest:: @@ -124,10 +124,10 @@ the :class:`~scim2_models.filters.AttrPath` it applies to: op=, value='bjensen'), Present(attr_path=AttrPath(attr='title', sub_attr=None, uri=None)))) -:class:`~scim2_models.filters.FilterVisitor` is a class that walks such a tree. You subclass it and +:class:`~scim2_models.path.FilterVisitor` is a class that walks such a tree. You subclass it and write what to do for each type of node. For a ``LogicalExpr``, write ``AND`` or ``OR`` between its terms; for a ``Comparison``, ``column = ?``; for a ``Present``, ``column IS NOT NULL``. -:meth:`~scim2_models.filters.FilterVisitor.visit` dispatches a node to the method of its type and +:meth:`~scim2_models.path.FilterVisitor.visit` dispatches a node to the method of its type and returns what that method returns. A subclass that turns a filter into a query for another system is called a transpiler below. @@ -144,14 +144,14 @@ stored in one column, ``meta.lastModified`` in ``meta_last_modified``. A node on :meth:`ScimFilter.resolve `. The result names the field holding the attribute, and says whether the comparison ignores case. The values do not go into the SQL. The visitor collects them in ``params``, which the database driver sends separately from the -query. :func:`~scim2_models.filters.coerce_value` converts each one beforehand, from the JSON value +query. :func:`~scim2_models.path.coerce_value` converts each one beforehand, from the JSON value the filter carries to the Python value the attribute holds. The visitor refuses whatever is harder in SQL than in SCIM, instead of guessing: .. doctest:: >>> from scim2_models import Attribute - >>> from scim2_models.filters import FilterVisitor, LogicalOperator, coerce_value + >>> from scim2_models.path import FilterVisitor, LogicalOperator, coerce_value >>> SQL_OPERATORS = { ... CompareOperator.eq: "=", @@ -394,7 +394,7 @@ The RFC leaves seven further choices open: Substring operators ``co``, ``sw`` and ``ew`` match a fragment rather than a whole value, so - :func:`~scim2_models.filters.coerce_value` leaves their operand as it is instead of converting + :func:`~scim2_models.path.coerce_value` leaves their operand as it is instead of converting it to the type of the attribute. The bound filter accepts ``emails[value co "example"]`` even though ``"example"`` is not a valid email address on its own. @@ -426,7 +426,7 @@ Case-insensitive comparison emitting SQL ``LOWER()`` folds ASCII only, and departs from ``match`` on such a value. Coercion of comparison values - :func:`~scim2_models.filters.coerce_value` reads a comparison value as JSON, then converts it + :func:`~scim2_models.path.coerce_value` reads a comparison value as JSON, then converts it to the type of the attribute. The conversion is the tolerant one of pydantic, so it reads ``active eq "yes"`` and ``active eq 1`` as ``active eq true`` instead of rejecting them as ``invalidFilter``. This tolerance makes the ``roles[primary eq "True"]`` that Microsoft diff --git a/doc/guides/_examples/sqlalchemy_example.py b/doc/guides/_examples/sqlalchemy_example.py index 55fd714e..dab57016 100644 --- a/doc/guides/_examples/sqlalchemy_example.py +++ b/doc/guides/_examples/sqlalchemy_example.py @@ -25,12 +25,12 @@ from scim2_models import Meta from scim2_models import SearchRequest from scim2_models import User -from scim2_models.filters import STRING_OPERATORS -from scim2_models.filters import AttrPath -from scim2_models.filters import CompareOperator -from scim2_models.filters import FilterVisitor -from scim2_models.filters import LogicalOperator -from scim2_models.filters import coerce_value +from scim2_models.path import STRING_OPERATORS +from scim2_models.path import AttrPath +from scim2_models.path import CompareOperator +from scim2_models.path import FilterVisitor +from scim2_models.path import LogicalOperator +from scim2_models.path import coerce_value from .integrations import MAX_RESULTS diff --git a/doc/reference.rst b/doc/reference.rst index ad4d5bc2..82e3386e 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -16,17 +16,17 @@ This page presents all the models provided by scim2-models. .. automodule:: scim2_models :members: -Filters -======= +Paths and filters +================= -The filter abstract syntax tree and the tools to walk it, used to turn a SCIM +The path and filter abstract syntax tree and the tools to walk it, used to turn a SCIM filter into a backend query. See :ref:`filter-transpiling` for a worked example. -.. automodule:: scim2_models.filters +.. automodule:: scim2_models.path :members: - :exclude-members: ScimFilter + :exclude-members: Path, ResolvedAttribute, ScimFilter -.. currentmodule:: scim2_models.filters +.. currentmodule:: scim2_models.path .. data:: ORDERING_OPERATORS :type: frozenset[CompareOperator] diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index 4189308a..d3a46c50 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -32,7 +32,6 @@ from .exceptions import SensitiveException from .exceptions import TooManyException from .exceptions import UniquenessException -from .filters import ScimFilter from .lookup import get_model_by_payload from .lookup import get_model_by_schema from .messages.bulk import BulkOperation @@ -46,10 +45,11 @@ from .messages.response_parameters import ResponseParameters from .messages.search_request import SearchRequest from .path import Path +from .path import ResolvedAttribute +from .path import ScimFilter from .reference import URI from .reference import External from .reference import Reference -from .resolution import ResolvedAttribute from .resources.enterprise_user import EnterpriseUser from .resources.enterprise_user import Manager from .resources.group import Group diff --git a/scim2_models/filters/__init__.py b/scim2_models/filters/__init__.py deleted file mode 100644 index e5a0b86a..00000000 --- a/scim2_models/filters/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -from ..expressions import ORDERING_OPERATORS -from ..expressions import STRING_OPERATORS -from ..expressions import AttrPath -from ..expressions import CompareOperator -from ..expressions import Comparison -from ..expressions import FilterNode -from ..expressions import LogicalExpr -from ..expressions import LogicalOperator -from ..expressions import Not -from ..expressions import PathNode -from ..expressions import Present -from ..expressions import ValuePath -from ..grammar import parse_filter -from ..grammar import parse_path -from ..resolution import attribute_host -from ..resolution import coerce_value -from ..resolution import resolve_attr_path -from ..resolution import resolve_comparison_path -from ..resolution import validate_operator -from ..resolution import validate_value_selection -from .filter import ScimFilter -from .filter import validate_value_filter -from .visitor import Evaluator -from .visitor import FilterVisitor -from .visitor import compare -from .visitor import is_present - -__all__ = [ - "ORDERING_OPERATORS", - "STRING_OPERATORS", - "AttrPath", - "CompareOperator", - "Comparison", - "Evaluator", - "FilterNode", - "FilterVisitor", - "LogicalExpr", - "LogicalOperator", - "Not", - "PathNode", - "Present", - "ScimFilter", - "ValuePath", - "attribute_host", - "coerce_value", - "compare", - "is_present", - "parse_filter", - "parse_path", - "resolve_attr_path", - "resolve_comparison_path", - "validate_operator", - "validate_value_filter", - "validate_value_selection", -] diff --git a/scim2_models/messages/patch_op.py b/scim2_models/messages/patch_op.py index 83771594..116ae712 100644 --- a/scim2_models/messages/patch_op.py +++ b/scim2_models/messages/patch_op.py @@ -21,7 +21,7 @@ from ..exceptions import MutabilityException from ..exceptions import NoTargetException from ..path import Path -from ..resolution import attribute_host +from ..path import attribute_host from ..resources.resource import Resource from ..urn import URN from ..utils import _find_field_name diff --git a/scim2_models/messages/response_parameters.py b/scim2_models/messages/response_parameters.py index 412feb47..660f4ed8 100644 --- a/scim2_models/messages/response_parameters.py +++ b/scim2_models/messages/response_parameters.py @@ -7,7 +7,7 @@ from ..base import BaseModel from ..exceptions import InvalidPathException from ..path import Path -from ..path import ResourceT +from ..path.path import ResourceT class ResponseParameters(BaseModel, Generic[ResourceT]): diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 637e9531..96726e0d 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -6,9 +6,9 @@ from ..exceptions import InvalidFilterException from ..exceptions import InvalidPathException -from ..filters import ScimFilter from ..path import Path -from ..path import ResourceT +from ..path import ScimFilter +from ..path.path import ResourceT from ..urn import URN from .message import Message from .response_parameters import ResponseParameters diff --git a/scim2_models/path/__init__.py b/scim2_models/path/__init__.py new file mode 100644 index 00000000..f6f99611 --- /dev/null +++ b/scim2_models/path/__init__.py @@ -0,0 +1,59 @@ +from .expressions import ORDERING_OPERATORS +from .expressions import STRING_OPERATORS +from .expressions import AttrPath +from .expressions import CompareOperator +from .expressions import Comparison +from .expressions import FilterNode +from .expressions import LogicalExpr +from .expressions import LogicalOperator +from .expressions import Not +from .expressions import PathNode +from .expressions import Present +from .expressions import ValuePath +from .filter import ScimFilter +from .filter import validate_value_filter +from .grammar import parse_filter +from .grammar import parse_path +from .path import Path +from .resolution import ResolvedAttribute +from .resolution import attribute_host +from .resolution import coerce_value +from .resolution import resolve_attr_path +from .resolution import resolve_comparison_path +from .resolution import validate_operator +from .resolution import validate_value_selection +from .visitor import Evaluator +from .visitor import FilterVisitor +from .visitor import compare +from .visitor import is_present + +__all__ = [ + "ORDERING_OPERATORS", + "STRING_OPERATORS", + "AttrPath", + "CompareOperator", + "Comparison", + "Evaluator", + "FilterNode", + "FilterVisitor", + "LogicalExpr", + "LogicalOperator", + "Not", + "Path", + "PathNode", + "Present", + "ResolvedAttribute", + "ScimFilter", + "ValuePath", + "attribute_host", + "coerce_value", + "compare", + "is_present", + "parse_filter", + "parse_path", + "resolve_attr_path", + "resolve_comparison_path", + "validate_operator", + "validate_value_filter", + "validate_value_selection", +] diff --git a/scim2_models/access.py b/scim2_models/path/access.py similarity index 97% rename from scim2_models/access.py rename to scim2_models/path/access.py index 0832cf79..db3af593 100644 --- a/scim2_models/access.py +++ b/scim2_models/path/access.py @@ -6,19 +6,19 @@ from typing import get_args from typing import get_origin -from .base import BaseModel -from .exceptions import InvalidPathException -from .exceptions import NoTargetException -from .exceptions import PathNotFoundException -from .filters.filter import validate_value_filter -from .filters.visitor import Evaluator +from ..base import BaseModel +from ..exceptions import InvalidPathException +from ..exceptions import NoTargetException +from ..exceptions import PathNotFoundException +from ..utils import UNION_TYPES +from ..utils import _find_field_name +from .filter import validate_value_filter from .resolution import _target_model from .resolution import attribute_host from .resolution import designated_model from .resolution import resolve_attr_path from .resolution import validate_value_selection -from .utils import UNION_TYPES -from .utils import _find_field_name +from .visitor import Evaluator if TYPE_CHECKING: from .path import Path @@ -111,7 +111,7 @@ def _walk( :raises PathNotFoundException: If the path names an attribute the model does not declare, or a sub-attribute of one that has none. """ - from .resources.resource import Resource + from ..resources.resource import Resource if path.ast is None: return _Root(explicit=False) diff --git a/scim2_models/binding.py b/scim2_models/path/binding.py similarity index 97% rename from scim2_models/binding.py rename to scim2_models/path/binding.py index 61fad598..5af6c751 100644 --- a/scim2_models/binding.py +++ b/scim2_models/path/binding.py @@ -8,11 +8,11 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import core_schema -from .exceptions import SCIMException -from .utils import _model_union +from ..exceptions import SCIMException +from ..utils import _model_union if TYPE_CHECKING: - from .base import BaseModel + from ..base import BaseModel _BOUND_CLASSES: "MutableMapping[tuple[type, tuple[type, ...]], type]" = ( WeakValueDictionary() diff --git a/scim2_models/expressions.py b/scim2_models/path/expressions.py similarity index 99% rename from scim2_models/expressions.py rename to scim2_models/path/expressions.py index d1fbbd32..a315de12 100644 --- a/scim2_models/expressions.py +++ b/scim2_models/path/expressions.py @@ -190,7 +190,7 @@ class FilterNode: Nodes compose with the Python boolean operators, which is how a filter is built without going through a string:: - >>> from scim2_models.filters import AttrPath, Comparison, CompareOperator, Present + >>> from scim2_models.path import AttrPath, Comparison, CompareOperator, Present >>> work = Comparison(AttrPath("type"), CompareOperator.eq, "work") >>> primary = Present(AttrPath("primary")) diff --git a/scim2_models/filters/filter.py b/scim2_models/path/filter.py similarity index 94% rename from scim2_models/filters/filter.py rename to scim2_models/path/filter.py index a84c00ce..e475e601 100644 --- a/scim2_models/filters/filter.py +++ b/scim2_models/path/filter.py @@ -5,24 +5,24 @@ from typing import TypeVar from ..base import BaseModel -from ..binding import _BoundToModels -from ..expressions import AttrPath -from ..expressions import Comparison -from ..expressions import FilterNode -from ..expressions import LogicalExpr -from ..expressions import Not -from ..expressions import Present -from ..expressions import Template -from ..expressions import ValuePath -from ..expressions import _Expression -from ..expressions import _quote -from ..expressions import _text -from ..grammar import parse_filter -from ..resolution import ResolvedAttribute -from ..resolution import coerce_value -from ..resolution import resolve_filter_path -from ..resolution import validate_operator -from ..resolution import validate_value_selection +from .binding import _BoundToModels +from .expressions import AttrPath +from .expressions import Comparison +from .expressions import FilterNode +from .expressions import LogicalExpr +from .expressions import Not +from .expressions import Present +from .expressions import Template +from .expressions import ValuePath +from .expressions import _Expression +from .expressions import _quote +from .expressions import _text +from .grammar import parse_filter +from .resolution import ResolvedAttribute +from .resolution import coerce_value +from .resolution import resolve_filter_path +from .resolution import validate_operator +from .resolution import validate_value_selection from .visitor import Evaluator from .visitor import FilterVisitor @@ -191,7 +191,7 @@ def ast(self) -> FilterNode: """The parsed form of the filter. This is the tree a transpiler walks with a - :class:`~scim2_models.filters.FilterVisitor`. + :class:`~scim2_models.path.FilterVisitor`. """ return self._ast @@ -263,7 +263,7 @@ def resolve( :raises InvalidFilterException: If ``strict`` and the attribute is unknown. >>> from scim2_models import ScimFilter, User - >>> from scim2_models.filters import AttrPath + >>> from scim2_models.path import AttrPath >>> resolved = ScimFilter[User]('emails.type eq "work"').resolve( ... AttrPath(attr="emails", sub_attr="type") @@ -287,7 +287,7 @@ def resolve_comparison( which the sub-attribute carries rather than the attribute holding it. :param attr_path: The attribute path to resolve, usually taken from a - :class:`~scim2_models.filters.Comparison` node of :attr:`ast`. + :class:`~scim2_models.path.Comparison` node of :attr:`ast`. :param strict: Whether an unknown attribute raises instead of returning :data:`None`. :returns: The resolved attribute, or :data:`None` when the filter is @@ -295,7 +295,7 @@ def resolve_comparison( :raises InvalidFilterException: If ``strict`` and the attribute is unknown. >>> from scim2_models import Group, ScimFilter - >>> from scim2_models.filters import AttrPath + >>> from scim2_models.path import AttrPath >>> scim_filter = ScimFilter[Group]('members co "2819c223"') >>> resolved = scim_filter.resolve_comparison(AttrPath(attr="members")) diff --git a/scim2_models/grammar.py b/scim2_models/path/grammar.py similarity index 97% rename from scim2_models/grammar.py rename to scim2_models/path/grammar.py index ec6ca0ce..74856b11 100644 --- a/scim2_models/grammar.py +++ b/scim2_models/path/grammar.py @@ -11,8 +11,8 @@ from lark.exceptions import LarkError from lark.exceptions import VisitError -from .exceptions import InvalidFilterException -from .exceptions import InvalidPathException +from ..exceptions import InvalidFilterException +from ..exceptions import InvalidPathException from .expressions import _ATTR_NAME from .expressions import _URN from .expressions import AttrPath @@ -143,7 +143,7 @@ def _select_attr_path(raw: str) -> AttrPath: @v_args(inline=True) class _AstBuilder(Transformer[Token, Any]): - """Turn a lark parse tree into the nodes of :mod:`scim2_models.expressions`.""" + """Turn a lark parse tree into the nodes of :mod:`scim2_models.path.expressions`.""" def or_expr(self, *terms: FilterNode) -> FilterNode: return LogicalExpr(op=LogicalOperator.or_, terms=terms) @@ -221,7 +221,7 @@ def parse_filter(expression: str) -> FilterNode: :returns: The root node of the parsed expression. :raises InvalidFilterException: If the expression is syntactically invalid. - >>> from scim2_models.filters import parse_filter + >>> from scim2_models.path import parse_filter >>> parse_filter('userName eq "bjensen"') Comparison(attr_path=AttrPath(attr='userName', sub_attr=None, uri=None), op=, value='bjensen') """ @@ -242,7 +242,7 @@ def parse_path(path: str) -> PathNode: :returns: The parsed path. :raises InvalidPathException: If the path is syntactically invalid. - >>> from scim2_models.filters import parse_path + >>> from scim2_models.path import parse_path >>> parse_path("name.familyName") AttrPath(attr='name', sub_attr='familyName', uri=None) """ diff --git a/scim2_models/path.py b/scim2_models/path/path.py similarity index 96% rename from scim2_models/path.py rename to scim2_models/path/path.py index 66082a0c..fa960fb5 100644 --- a/scim2_models/path.py +++ b/scim2_models/path/path.py @@ -6,25 +6,25 @@ from typing import Generic from typing import TypeVar +from ..base import BaseModel +from ..urn import URN +from ..utils import _to_camel from .access import delete_value from .access import get_value from .access import set_value -from .base import BaseModel from .binding import _BoundToModels -from .urn import URN -from .utils import _to_camel if TYPE_CHECKING: - from .annotations import CaseExact - from .annotations import Mutability - from .annotations import Required - from .annotations import Returned - from .annotations import Uniqueness - from .resources.resource import Resource - -from .exceptions import InvalidFilterException -from .exceptions import InvalidPathException -from .exceptions import NoTargetException + from ..annotations import CaseExact + from ..annotations import Mutability + from ..annotations import Required + from ..annotations import Returned + from ..annotations import Uniqueness + from ..resources.resource import Resource + +from ..exceptions import InvalidFilterException +from ..exceptions import InvalidPathException +from ..exceptions import NoTargetException from .expressions import AttrPath from .expressions import Comparison from .expressions import FilterNode @@ -508,14 +508,14 @@ def iter_paths( :param case_exact: Filter by CaseExact annotation values (e.g., [CaseExact.true]). :yields: Path instances for each attribute matching the filters. """ - from .annotations import CaseExact - from .annotations import Mutability - from .annotations import Required - from .annotations import Returned - from .annotations import Uniqueness - from .attributes import ComplexAttribute - from .resources.resource import Extension - from .resources.resource import Resource + from ..annotations import CaseExact + from ..annotations import Mutability + from ..annotations import Required + from ..annotations import Returned + from ..annotations import Uniqueness + from ..attributes import ComplexAttribute + from ..resources.resource import Extension + from ..resources.resource import Resource if len(cls.__scim_models__) != 1: raise TypeError( diff --git a/scim2_models/resolution.py b/scim2_models/path/resolution.py similarity index 97% rename from scim2_models/resolution.py rename to scim2_models/path/resolution.py index cfcb7dfa..4ac20edd 100644 --- a/scim2_models/resolution.py +++ b/scim2_models/path/resolution.py @@ -8,15 +8,15 @@ from pydantic import TypeAdapter from pydantic import ValidationError -from .annotations import CaseExact -from .base import BaseModel -from .exceptions import InvalidFilterException -from .exceptions import PathNotFoundException +from ..annotations import CaseExact +from ..base import BaseModel +from ..exceptions import InvalidFilterException +from ..exceptions import PathNotFoundException +from ..utils import _find_field_name from .expressions import ORDERING_OPERATORS from .expressions import STRING_OPERATORS from .expressions import AttrPath from .expressions import CompareOperator -from .utils import _find_field_name # Python types that RFC7644 §3.4.2.2 forbids comparing with an ordering operator. _UNORDERABLE_TYPES = (bool, bytes) @@ -127,7 +127,7 @@ def attribute_host(obj: Any, resolved: ResolvedAttribute) -> Any: :returns: The object to read the attribute from, or :data:`None` when the extension holding it is not set. """ - from .resources.resource import Extension + from ..resources.resource import Extension model = resolved.model if not (isclass(model) and issubclass(model, Extension)): @@ -139,7 +139,7 @@ def attribute_host(obj: Any, resolved: ResolvedAttribute) -> Any: def _extension_models(model: type[BaseModel]) -> dict[str, type[BaseModel]]: """Return the extension models of a resource, keyed by schema URN.""" - from .resources.resource import Resource + from ..resources.resource import Resource if not (isclass(model) and issubclass(model, Resource)): return {} @@ -160,8 +160,8 @@ def designated_model(model: type[BaseModel], path: str) -> type[BaseModel] | Non :returns: The model the path is the schema of, or :data:`None` when it is not one. """ - from .resources.resource import Extension - from .resources.resource import Resource + from ..resources.resource import Extension + from ..resources.resource import Resource if not (isclass(model) and issubclass(model, Resource | Extension)): return None @@ -187,8 +187,8 @@ def _target_model( :raises PathNotFoundException: If ``strict`` and the URN designates no model. """ - from .resources.resource import Extension - from .resources.resource import Resource + from ..resources.resource import Extension + from ..resources.resource import Resource if attr_path.uri is None: return model @@ -344,7 +344,7 @@ def resolve_comparison_path( :raises PathNotFoundException: If ``strict`` and the attribute is unknown. >>> from scim2_models import User - >>> from scim2_models.filters import AttrPath, resolve_comparison_path + >>> from scim2_models.path import AttrPath, resolve_comparison_path >>> resolve_comparison_path(User, AttrPath("emails")).sub_field_name 'value' diff --git a/scim2_models/filters/visitor.py b/scim2_models/path/visitor.py similarity index 95% rename from scim2_models/filters/visitor.py rename to scim2_models/path/visitor.py index 48b15261..eb6bf488 100644 --- a/scim2_models/filters/visitor.py +++ b/scim2_models/path/visitor.py @@ -4,21 +4,21 @@ from unicodedata import normalize from ..base import BaseModel -from ..expressions import AttrPath -from ..expressions import CompareOperator -from ..expressions import Comparison -from ..expressions import FilterNode -from ..expressions import LogicalExpr -from ..expressions import LogicalOperator -from ..expressions import Not -from ..expressions import Present -from ..expressions import ValuePath -from ..resolution import ResolvedAttribute -from ..resolution import attribute_host -from ..resolution import coerce_value -from ..resolution import resolve_filter_path -from ..resolution import validate_operator -from ..resolution import validate_value_selection +from .expressions import AttrPath +from .expressions import CompareOperator +from .expressions import Comparison +from .expressions import FilterNode +from .expressions import LogicalExpr +from .expressions import LogicalOperator +from .expressions import Not +from .expressions import Present +from .expressions import ValuePath +from .resolution import ResolvedAttribute +from .resolution import attribute_host +from .resolution import coerce_value +from .resolution import resolve_filter_path +from .resolution import validate_operator +from .resolution import validate_value_selection T = TypeVar("T") diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index c791b927..2a84165b 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -12,8 +12,8 @@ from scim2_models.annotations import Uniqueness from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context -from scim2_models.filters import ScimFilter from scim2_models.path import Path +from scim2_models.path import ScimFilter from scim2_models.reference import URI from scim2_models.reference import External from scim2_models.reference import Reference diff --git a/tests/test_filter_grammar.py b/tests/test_filter_grammar.py index 189596bc..6a16a8a6 100644 --- a/tests/test_filter_grammar.py +++ b/tests/test_filter_grammar.py @@ -6,18 +6,18 @@ from scim2_models import InvalidFilterException from scim2_models import InvalidPathException from scim2_models import ScimFilter -from scim2_models.filters import AttrPath -from scim2_models.filters import CompareOperator -from scim2_models.filters import Comparison -from scim2_models.filters import FilterNode -from scim2_models.filters import FilterVisitor -from scim2_models.filters import LogicalExpr -from scim2_models.filters import LogicalOperator -from scim2_models.filters import Not -from scim2_models.filters import Present -from scim2_models.filters import ValuePath -from scim2_models.filters import parse_filter -from scim2_models.filters import parse_path +from scim2_models.path import AttrPath +from scim2_models.path import CompareOperator +from scim2_models.path import Comparison +from scim2_models.path import FilterNode +from scim2_models.path import FilterVisitor +from scim2_models.path import LogicalExpr +from scim2_models.path import LogicalOperator +from scim2_models.path import Not +from scim2_models.path import Present +from scim2_models.path import ValuePath +from scim2_models.path import parse_filter +from scim2_models.path import parse_path @pytest.mark.parametrize( diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py index 1d8a80c7..4fefef2f 100644 --- a/tests/test_filter_semantics.py +++ b/tests/test_filter_semantics.py @@ -21,22 +21,22 @@ from scim2_models import ScimFilter from scim2_models import SearchRequest from scim2_models import User -from scim2_models.filters import AttrPath -from scim2_models.filters import CompareOperator -from scim2_models.filters import Comparison -from scim2_models.filters import FilterVisitor -from scim2_models.filters import LogicalExpr -from scim2_models.filters import LogicalOperator -from scim2_models.filters import Not -from scim2_models.filters import Present -from scim2_models.filters import ValuePath -from scim2_models.filters import coerce_value -from scim2_models.filters import compare -from scim2_models.filters import is_present -from scim2_models.filters import parse_filter -from scim2_models.filters import resolve_attr_path -from scim2_models.filters import resolve_comparison_path -from scim2_models.grammar import _error_detail +from scim2_models.path import AttrPath +from scim2_models.path import CompareOperator +from scim2_models.path import Comparison +from scim2_models.path import FilterVisitor +from scim2_models.path import LogicalExpr +from scim2_models.path import LogicalOperator +from scim2_models.path import Not +from scim2_models.path import Present +from scim2_models.path import ValuePath +from scim2_models.path import coerce_value +from scim2_models.path import compare +from scim2_models.path import is_present +from scim2_models.path import parse_filter +from scim2_models.path import resolve_attr_path +from scim2_models.path import resolve_comparison_path +from scim2_models.path.grammar import _error_detail from scim2_models.resources.user import Email COMPOSED = "Jos\N{LATIN SMALL LETTER E WITH ACUTE}" diff --git a/tests/test_template_strings.py b/tests/test_template_strings.py index dde1835a..a04606b3 100644 --- a/tests/test_template_strings.py +++ b/tests/test_template_strings.py @@ -11,8 +11,8 @@ from scim2_models import Path from scim2_models import ScimFilter from scim2_models import User -from scim2_models.filters import Comparison -from scim2_models.filters import LogicalExpr +from scim2_models.path import Comparison +from scim2_models.path import LogicalExpr def test_an_interpolated_string_stays_a_value_whatever_it_contains(): From a81b9b126f11be855c6185285bf8670cfe2ae8c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 17:41:39 +0200 Subject: [PATCH 12/14] doc: state the theme of each module of scim2_models.path A one-line docstring opens each module of the package, so that a reader tells apart what answers for the grammar, the syntax tree, the resolution against a model, the evaluation, and the access to an instance. --- scim2_models/path/__init__.py | 2 ++ scim2_models/path/access.py | 2 ++ scim2_models/path/binding.py | 2 ++ scim2_models/path/expressions.py | 2 ++ scim2_models/path/filter.py | 2 ++ scim2_models/path/grammar.py | 2 ++ scim2_models/path/path.py | 2 ++ scim2_models/path/resolution.py | 2 ++ scim2_models/path/visitor.py | 2 ++ 9 files changed, 18 insertions(+) diff --git a/scim2_models/path/__init__.py b/scim2_models/path/__init__.py index f6f99611..b52ffea4 100644 --- a/scim2_models/path/__init__.py +++ b/scim2_models/path/__init__.py @@ -1,3 +1,5 @@ +"""Attribute paths and filters, from their grammar to their evaluation on a resource.""" + from .expressions import ORDERING_OPERATORS from .expressions import STRING_OPERATORS from .expressions import AttrPath diff --git a/scim2_models/path/access.py b/scim2_models/path/access.py index db3af593..9f3c9b43 100644 --- a/scim2_models/path/access.py +++ b/scim2_models/path/access.py @@ -1,3 +1,5 @@ +"""Reading and writing a resource through a path, plain or selecting values.""" + from inspect import isclass from typing import TYPE_CHECKING from typing import Any diff --git a/scim2_models/path/binding.py b/scim2_models/path/binding.py index 5af6c751..42d3594e 100644 --- a/scim2_models/path/binding.py +++ b/scim2_models/path/binding.py @@ -1,3 +1,5 @@ +"""The subscription binding a string type to resource models, as in ``Path[User]``.""" + from collections.abc import MutableMapping from typing import TYPE_CHECKING from typing import Any diff --git a/scim2_models/path/expressions.py b/scim2_models/path/expressions.py index a315de12..ee301a4d 100644 --- a/scim2_models/path/expressions.py +++ b/scim2_models/path/expressions.py @@ -1,3 +1,5 @@ +"""The syntax tree that filters and paths are parsed into, and rendered back from.""" + import json import re import sys diff --git a/scim2_models/path/filter.py b/scim2_models/path/filter.py index e475e601..47379e93 100644 --- a/scim2_models/path/filter.py +++ b/scim2_models/path/filter.py @@ -1,3 +1,5 @@ +"""A filter expression as a string, bound to the models it is validated against.""" + from inspect import isclass from typing import TYPE_CHECKING from typing import Any diff --git a/scim2_models/path/grammar.py b/scim2_models/path/grammar.py index 74856b11..54edf638 100644 --- a/scim2_models/path/grammar.py +++ b/scim2_models/path/grammar.py @@ -1,3 +1,5 @@ +"""The RFC 7644 grammar of filters and paths, and the parsers that build the syntax tree.""" + import json from functools import lru_cache from math import isfinite diff --git a/scim2_models/path/path.py b/scim2_models/path/path.py index fa960fb5..1749bb6f 100644 --- a/scim2_models/path/path.py +++ b/scim2_models/path/path.py @@ -1,3 +1,5 @@ +"""An attribute path as a string, bound to the models it is resolved against.""" + from collections.abc import Iterator from dataclasses import replace from inspect import isclass diff --git a/scim2_models/path/resolution.py b/scim2_models/path/resolution.py index 4ac20edd..86e18e3c 100644 --- a/scim2_models/path/resolution.py +++ b/scim2_models/path/resolution.py @@ -1,3 +1,5 @@ +"""Binding a syntactic attribute path to the attribute a model declares.""" + from dataclasses import dataclass from dataclasses import replace from inspect import isclass diff --git a/scim2_models/path/visitor.py b/scim2_models/path/visitor.py index eb6bf488..e9e687a8 100644 --- a/scim2_models/path/visitor.py +++ b/scim2_models/path/visitor.py @@ -1,3 +1,5 @@ +"""Walking the syntax tree: the base of transpilers, and the in-memory evaluator.""" + from typing import Any from typing import Generic from typing import TypeVar From 0dd4ce317d3f56ea9def2a802bc20578cb8b08a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 17:41:41 +0200 Subject: [PATCH 13/14] refactor: rename ResolvedAttribute to AttributeBinding Next to Attribute, the description of an attribute a schema publishes, the name read as a variant of it. The object is the binding of a syntactic attribute path to the Python class and field it designates on a model, which is what the evaluator and transpilers consume, and the name now says so. --- doc/changelog.rst | 2 +- doc/guides/sqlalchemy.rst | 2 +- doc/reference.rst | 2 +- scim2_models/__init__.py | 4 ++-- scim2_models/path/__init__.py | 4 ++-- scim2_models/path/filter.py | 10 +++++----- scim2_models/path/path.py | 4 ++-- scim2_models/path/resolution.py | 28 ++++++++++++++-------------- scim2_models/path/visitor.py | 8 ++++---- tests/test_filter_semantics.py | 4 ++-- tests/test_path.py | 4 ++-- 11 files changed, 36 insertions(+), 36 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 7d797c7e..fbcb4357 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -25,7 +25,7 @@ Added :rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>` requires of a root query; a path resolves against the first type declaring it, so ``sortBy`` answers on a root query too. - :meth:`Path.resolve ` resolves a path to the - :class:`~scim2_models.ResolvedAttribute` it designates. + :class:`~scim2_models.AttributeBinding` it designates. - lark is a new dependency. Changed diff --git a/doc/guides/sqlalchemy.rst b/doc/guides/sqlalchemy.rst index 12d08c6a..5a51a2f5 100644 --- a/doc/guides/sqlalchemy.rst +++ b/doc/guides/sqlalchemy.rst @@ -107,7 +107,7 @@ never as text inside the query. Two decisions remain, and neither is automated: -- **Case**, read from :attr:`~scim2_models.ResolvedAttribute.case_exact` and the type of the +- **Case**, read from :attr:`~scim2_models.AttributeBinding.case_exact` and the type of the attribute. ``icontains`` and ``contains`` differ by one letter and by which resources they return. - **The three-valued logic of SQL**, where ``NULL <> 'Manager'`` is ``NULL`` and not true. A diff --git a/doc/reference.rst b/doc/reference.rst index 82e3386e..e56c1d8c 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -24,7 +24,7 @@ filter into a backend query. See :ref:`filter-transpiling` for a worked example. .. automodule:: scim2_models.path :members: - :exclude-members: Path, ResolvedAttribute, ScimFilter + :exclude-members: Path, AttributeBinding, ScimFilter .. currentmodule:: scim2_models.path diff --git a/scim2_models/__init__.py b/scim2_models/__init__.py index d3a46c50..5c71109b 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -44,8 +44,8 @@ from .messages.patch_op import PatchOperation from .messages.response_parameters import ResponseParameters from .messages.search_request import SearchRequest +from .path import AttributeBinding from .path import Path -from .path import ResolvedAttribute from .path import ScimFilter from .reference import URI from .reference import External @@ -92,6 +92,7 @@ "AnyResource", "AnyScimObject", "Attribute", + "AttributeBinding", "AuthenticationScheme", "BaseModel", "Bulk", @@ -146,7 +147,6 @@ "ReplacementRequestContext", "ReplacementResponseContext", "Required", - "ResolvedAttribute", "Resource", "ResourceType", "ResponseParameters", diff --git a/scim2_models/path/__init__.py b/scim2_models/path/__init__.py index b52ffea4..b7262eb4 100644 --- a/scim2_models/path/__init__.py +++ b/scim2_models/path/__init__.py @@ -17,7 +17,7 @@ from .grammar import parse_filter from .grammar import parse_path from .path import Path -from .resolution import ResolvedAttribute +from .resolution import AttributeBinding from .resolution import attribute_host from .resolution import coerce_value from .resolution import resolve_attr_path @@ -33,6 +33,7 @@ "ORDERING_OPERATORS", "STRING_OPERATORS", "AttrPath", + "AttributeBinding", "CompareOperator", "Comparison", "Evaluator", @@ -44,7 +45,6 @@ "Path", "PathNode", "Present", - "ResolvedAttribute", "ScimFilter", "ValuePath", "attribute_host", diff --git a/scim2_models/path/filter.py b/scim2_models/path/filter.py index 47379e93..8fcc21bd 100644 --- a/scim2_models/path/filter.py +++ b/scim2_models/path/filter.py @@ -20,7 +20,7 @@ from .expressions import _quote from .expressions import _text from .grammar import parse_filter -from .resolution import ResolvedAttribute +from .resolution import AttributeBinding from .resolution import coerce_value from .resolution import resolve_filter_path from .resolution import validate_operator @@ -44,7 +44,7 @@ def __init__(self, *models: type[BaseModel], strict: bool, urn_prefix: str = "") def _resolve( self, attr_path: AttrPath, *, for_comparison: bool = False - ) -> ResolvedAttribute | None: + ) -> AttributeBinding | None: """Bind an attribute path to the first model of the union declaring it. An endpoint covering several resource types accepts a filter naming an @@ -98,7 +98,7 @@ def visit_value_path(self, node: ValuePath) -> None: def validate_value_filter( - resolved: ResolvedAttribute, val_filter: FilterNode, *, strict: bool = True + resolved: AttributeBinding, val_filter: FilterNode, *, strict: bool = True ) -> None: """Check the filter of a value selection against the attribute it selects from. @@ -248,7 +248,7 @@ def model(self) -> type[BaseModel] | None: def resolve( self, attr_path: AttrPath, *, strict: bool = True - ) -> ResolvedAttribute | None: + ) -> AttributeBinding | None: """Bind an attribute path of this filter to the model it designates. The path is resolved as it is written, which is what a presence test @@ -279,7 +279,7 @@ def resolve( def resolve_comparison( self, attr_path: AttrPath, *, strict: bool = True - ) -> ResolvedAttribute | None: + ) -> AttributeBinding | None: """Bind the attribute path of a comparison to the model it designates. A comparison against a multi-valued complex attribute applies to its diff --git a/scim2_models/path/path.py b/scim2_models/path/path.py index 1749bb6f..abe9f4c3 100644 --- a/scim2_models/path/path.py +++ b/scim2_models/path/path.py @@ -37,7 +37,7 @@ from .expressions import _Expression from .expressions import _text from .grammar import parse_path -from .resolution import ResolvedAttribute +from .resolution import AttributeBinding from .resolution import designated_model from .resolution import resolve_attr_path @@ -253,7 +253,7 @@ def _designated_model(self) -> type[BaseModel] | None: return designated return None - def resolve(self) -> "ResolvedAttribute | None": + def resolve(self) -> "AttributeBinding | None": """Bind this path to the attribute it designates on the bound model. This is the single resolution the model-aware properties are built on. diff --git a/scim2_models/path/resolution.py b/scim2_models/path/resolution.py index 86e18e3c..b21ca7e8 100644 --- a/scim2_models/path/resolution.py +++ b/scim2_models/path/resolution.py @@ -39,7 +39,7 @@ def _unwrap_annotated(type_: Any) -> type | None: @dataclass(frozen=True) -class ResolvedAttribute: +class AttributeBinding: """A syntactic attribute path bound to the model it designates. Transpilers get everything they need to emit a query out of this: which @@ -108,7 +108,7 @@ def target_is_multivalued(self) -> bool | None: return None return model.get_field_multiplicity(self.target_field_name) - def nested_in(self, urn: str) -> "ResolvedAttribute": + def nested_in(self, urn: str) -> "AttributeBinding": """Return the same attribute, qualified by the URN it was resolved under. An attribute resolved inside a value selection is resolved against a @@ -118,7 +118,7 @@ def nested_in(self, urn: str) -> "ResolvedAttribute": return replace(self, urn=f"{urn}.{self.urn}") -def attribute_host(obj: Any, resolved: ResolvedAttribute) -> Any: +def attribute_host(obj: Any, resolved: AttributeBinding) -> Any: """Return the object actually holding a resolved attribute. An attribute qualified by an extension URN lives on the extension instance, @@ -225,7 +225,7 @@ def _target_model( def resolve_attr_path( model: type[BaseModel], attr_path: AttrPath, *, strict: bool = True -) -> ResolvedAttribute | None: +) -> AttributeBinding | None: """Bind an attribute path to the field it designates on a model. :param model: The resource or extension model to resolve against. @@ -234,14 +234,14 @@ def resolve_attr_path( :data:`None`. :returns: The resolved attribute, or :data:`None` when it cannot be resolved and ``strict`` is false. Results are cached and shared, which - is safe since :class:`ResolvedAttribute` is immutable. + is safe since :class:`AttributeBinding` is immutable. :raises PathNotFoundException: If ``strict`` and the attribute is unknown. """ # The cache is held by the model rather than by this module, so that it # cannot outlive the model it describes: a resolved attribute names the # model it was resolved on, which a cache keyed by that model would keep # alive for as long as the process runs. - cache: dict[tuple[AttrPath, bool], ResolvedAttribute | None] | None + cache: dict[tuple[AttrPath, bool], AttributeBinding | None] | None cache = model.__dict__.get(_RESOLVED_ATTRS) if cache is None: cache = {} @@ -255,7 +255,7 @@ def resolve_attr_path( def _resolve_attr_path( model: type[BaseModel], attr_path: AttrPath, *, strict: bool -) -> ResolvedAttribute | None: +) -> AttributeBinding | None: """Bind an attribute path, without going through the cache of the model.""" target = _target_model(model, attr_path, strict=strict) if target is None: @@ -301,7 +301,7 @@ def _resolve_attr_path( == CaseExact.true ) - return ResolvedAttribute( + return AttributeBinding( model=target, field_name=field_name, field_type=field_type, @@ -313,7 +313,7 @@ def _resolve_attr_path( ) -def _addresses_entry_values(resolved: ResolvedAttribute) -> bool: +def _addresses_entry_values(resolved: AttributeBinding) -> bool: """Whether a comparison against this attribute applies to ``value`` instead.""" return ( resolved.sub_field_name is None @@ -326,7 +326,7 @@ def _addresses_entry_values(resolved: ResolvedAttribute) -> bool: def resolve_comparison_path( model: type[BaseModel], attr_path: AttrPath, *, strict: bool = True -) -> ResolvedAttribute | None: +) -> AttributeBinding | None: """Bind the attribute a comparison applies to. :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` uses ``emails co @@ -366,7 +366,7 @@ def resolve_filter_path( *, strict: bool = True, for_comparison: bool = False, -) -> ResolvedAttribute | None: +) -> AttributeBinding | None: """Bind an attribute path taken from a filter. An attribute a model does not declare makes the *filter* invalid rather @@ -401,7 +401,7 @@ def _build_urn(model: type[BaseModel], attr_path: AttrPath) -> str: def coerce_value( - resolved: ResolvedAttribute, value: Any, op: CompareOperator | None = None + resolved: AttributeBinding, value: Any, op: CompareOperator | None = None ) -> Any: """Convert a raw comparison value to the Python type of its attribute. @@ -447,7 +447,7 @@ def coerce_value( ) from exc -def validate_operator(resolved: ResolvedAttribute, op: CompareOperator) -> None: +def validate_operator(resolved: AttributeBinding, op: CompareOperator) -> None: """Check that an operator may be applied to an attribute. :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>` requires boolean and binary @@ -471,7 +471,7 @@ def validate_operator(resolved: ResolvedAttribute, op: CompareOperator) -> None: ) -def validate_value_selection(resolved: ResolvedAttribute) -> None: +def validate_value_selection(resolved: AttributeBinding) -> None: """Check that a value selection applies to a multi-valued attribute. :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>` defines the ``valuePath`` rule diff --git a/scim2_models/path/visitor.py b/scim2_models/path/visitor.py index e9e687a8..ec8b80e8 100644 --- a/scim2_models/path/visitor.py +++ b/scim2_models/path/visitor.py @@ -15,7 +15,7 @@ from .expressions import Not from .expressions import Present from .expressions import ValuePath -from .resolution import ResolvedAttribute +from .resolution import AttributeBinding from .resolution import attribute_host from .resolution import coerce_value from .resolution import resolve_filter_path @@ -193,7 +193,7 @@ def __init__( def _resolve( self, attr_path: AttrPath, *, for_comparison: bool = False - ) -> ResolvedAttribute | None: + ) -> AttributeBinding | None: resolved = resolve_filter_path( self.model, attr_path, strict=self.strict, for_comparison=for_comparison ) @@ -201,7 +201,7 @@ def _resolve( return resolved return resolved.nested_in(self.urn_prefix) - def _read(self, resolved: ResolvedAttribute) -> Any: + def _read(self, resolved: AttributeBinding) -> Any: """Read the compared value off the object, flattening multi-valued attributes.""" host = attribute_host(self.obj, resolved) if host is None: @@ -299,7 +299,7 @@ def select(self, node: ValuePath) -> list[Any]: ] def _matches_item( - self, item: Any, val_filter: FilterNode, resolved: ResolvedAttribute + self, item: Any, val_filter: FilterNode, resolved: AttributeBinding ) -> bool: """Evaluate a value filter against one entry of a multi-valued attribute.""" if isinstance(item, BaseModel): diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py index 4fefef2f..31f9f3bb 100644 --- a/tests/test_filter_semantics.py +++ b/tests/test_filter_semantics.py @@ -8,6 +8,7 @@ from pydantic import BaseModel as PydanticBaseModel from pydantic import ValidationError +from scim2_models import AttributeBinding from scim2_models import EnterpriseUser from scim2_models import Extension from scim2_models import Group @@ -16,7 +17,6 @@ from scim2_models import Path from scim2_models import PathNotFoundException from scim2_models import Required -from scim2_models import ResolvedAttribute from scim2_models import Schema from scim2_models import ScimFilter from scim2_models import SearchRequest @@ -973,7 +973,7 @@ def test_a_failure_without_a_position_still_reports_a_reason(): def test_the_model_of_a_sub_attribute_on_a_simple_attribute_is_unknown(): """``target_model`` has nothing to point at when the head is not complex.""" - resolved = ResolvedAttribute( + resolved = AttributeBinding( model=User, field_name="user_name", field_type=str, diff --git a/tests/test_path.py b/tests/test_path.py index 9a7fa2e8..6038a502 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -7,6 +7,7 @@ import pytest from scim2_models import URN +from scim2_models import AttributeBinding from scim2_models import CaseExact from scim2_models import Email from scim2_models import EnterpriseUser @@ -20,7 +21,6 @@ from scim2_models import PatchOp from scim2_models import PathNotFoundException from scim2_models import Required -from scim2_models import ResolvedAttribute from scim2_models import Resource from scim2_models import Returned from scim2_models import Uniqueness @@ -1842,7 +1842,7 @@ def test_an_extension_urn_carries_an_attribute_behind_a_colon(): def test_resolving_a_path_binds_it_to_the_attribute_it_designates(): resolved = Path[User]("name.familyName").resolve() - assert isinstance(resolved, ResolvedAttribute) + assert isinstance(resolved, AttributeBinding) assert resolved.model is User assert resolved.field_name == "name" assert resolved.sub_field_name == "family_name" From 590116ca224101ea31c144784ad3a29f658c0817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Tue, 8 Sep 2026 18:14:50 +0200 Subject: [PATCH 14/14] 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. --- doc/changelog.rst | 11 + doc/guides/_examples/integrations.py | 28 +- doc/guides/index.rst | 4 +- scim2_models/messages/search_request.py | 2 +- scim2_models/path/path.py | 143 +++----- scim2_models/path/resolution.py | 16 + tests/test_doc_examples.py | 2 +- tests/test_dynamic_resources.py | 2 +- tests/test_filter_semantics.py | 1 + tests/test_path.py | 429 +++++++----------------- tests/test_path_value_selection.py | 49 +-- tests/test_search_request.py | 28 +- 12 files changed, 257 insertions(+), 458 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index fbcb4357..dc38f966 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -30,6 +30,11 @@ Added Changed ^^^^^^^ +- :attr:`Path.model ` answers the model a path designates when it names + no attribute: the bound model for the resource root, the resource or the extension for a bare + schema URN, and :data:`None` for a path naming an attribute. It used to answer the type holding + the attribute, which :meth:`Path.resolve ` now does through + :class:`~scim2_models.AttributeBinding`. - :attr:`SearchRequest.sort_by ` is resolved against the model a parameterised request names, and an attribute none of its resource types declares answers ``invalidPath`` at validation time instead of being carried to the endpoint. Where an @@ -44,6 +49,12 @@ Changed Removed ^^^^^^^ +- ``Path.field_name``, ``Path.field_type``, ``Path.is_multivalued``, ``Path.get_annotation`` and + ``Path.urn``. They read the attribute a path designates with the words of + :class:`~scim2_models.AttributeBinding` turned around, ``field_name`` naming the sub-attribute + where the binding names the head. :meth:`Path.resolve ` answers the + binding, which carries them as ``target_field_name``, ``target_type``, + ``target_is_multivalued``, :meth:`~scim2_models.AttributeBinding.get_annotation` and ``urn``. - ``Path.is_prefix_of`` and ``Path.has_prefix``. They compared the text of two paths, which anything between brackets defeated, and answered differently from the comparison :attr:`~scim2_models.ResponseParameters.attributes` and diff --git a/doc/guides/_examples/integrations.py b/doc/guides/_examples/integrations.py index 34626dc2..b37de559 100644 --- a/doc/guides/_examples/integrations.py +++ b/doc/guides/_examples/integrations.py @@ -9,7 +9,6 @@ from scim2_models import Bulk from scim2_models import ChangePassword from scim2_models import ComplexAttribute -from scim2_models import CaseExact from scim2_models import ETag from scim2_models import Filter from scim2_models import InvalidPathException @@ -23,6 +22,7 @@ from scim2_models import Sort from scim2_models import UniquenessException from scim2_models import User +from scim2_models.path import attribute_host # -- storage-start -- records = {} @@ -73,19 +73,19 @@ def sort_resources(resources, sort_by, sort_order=None): :param sort_order: The ``sortOrder`` query parameter, ascending by default. :raises InvalidPathException: If the attribute is unknown. """ - if sort_by.field_name is None: + resolved = sort_by.resolve() + if resolved is None: raise InvalidPathException( path=str(sort_by), detail=f"Cannot sort on {sort_by!r}" ) - # "String type attributes are case insensitive by default, unless the - # attribute type is defined as a case-exact string." - case_exact = sort_by.model.get_field_annotation(sort_by.field_name, CaseExact) descending = sort_order == SearchRequest.SortOrder.descending def key(resource): - value = sort_value(resource, sort_by) - if isinstance(value, str) and not case_exact: + value = sort_value(resource, resolved) + # "String type attributes are case insensitive by default, unless the + # attribute type is defined as a case-exact string." + if isinstance(value, str) and not resolved.case_exact: value = value.casefold() # "if there is no data for the specified sortBy value, they are sorted # via the sortOrder parameter, i.e., they are ordered last if ascending @@ -95,7 +95,7 @@ def key(resource): return sorted(resources, key=key, reverse=descending) -def sort_value(resource, sort_by): +def sort_value(resource, resolved): """Return the single value a resource is ordered by. A path crossing a multi-valued attribute designates the sub-attribute of @@ -103,15 +103,13 @@ def sort_value(resource, sort_by): picked first and the sub-attribute read from it. :param resource: The resource to read. - :param sort_by: The ``sortBy`` query parameter, resolved by its request. + :param resolved: The attribute the ``sortBy`` designates. """ - sub_field_name = sort_by.field_name if len(sort_by.parts) > 1 else None - attribute = sort_by - if sub_field_name is not None: - attribute = type(sort_by)(str(sort_by).rsplit(".", 1)[0]) - value = attribute.get(resource, strict=False) + host = attribute_host(resource, resolved) + value = None if host is None else getattr(host, resolved.field_name, None) + sub_field_name = resolved.sub_field_name - if attribute.is_multivalued: + if resolved.is_multivalued: entries = value or [] # "resources are sorted by the value of the primary attribute, if any, # or else the first value in the list, if any." diff --git a/doc/guides/index.rst b/doc/guides/index.rst index 9eccdcb9..a9602d56 100644 --- a/doc/guides/index.rst +++ b/doc/guides/index.rst @@ -75,8 +75,8 @@ Ordering and paging collections A collection endpoint answers the ``sortBy``, ``sortOrder``, ``startIndex`` and ``count`` parameters of :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2>`. Naming the resource type the endpoint serves, with :class:`~scim2_models.SearchRequest`\ [:class:`~scim2_models.User`], resolves -:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below reads -:attr:`Path.field_name ` instead of the attribute name a client +:attr:`~scim2_models.SearchRequest.sort_by` against that model, so the helper below works from +the :class:`~scim2_models.AttributeBinding` it designates instead of from the name a client spelled. :rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` decides the order in three ways, and the helper diff --git a/scim2_models/messages/search_request.py b/scim2_models/messages/search_request.py index 96726e0d..1123bd2b 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -92,7 +92,7 @@ def _resolvable_sort_by(cls, value: Any) -> Any: # Parameterising the request names the resource types the endpoint # serves, which is what makes an attribute none of them declares a # client error rather than something to resolve later. - if value is not None and value.models and value.field_name is None: + if value is not None and value.models and value.resolve() is None: raise InvalidPathException( path=str(value), detail=f"Cannot sort on {str(value)!r}" ).as_pydantic_error() diff --git a/scim2_models/path/path.py b/scim2_models/path/path.py index abe9f4c3..2ac2f4e5 100644 --- a/scim2_models/path/path.py +++ b/scim2_models/path/path.py @@ -57,6 +57,33 @@ def _scim_name(model: type[BaseModel], field_name: str) -> str: class Path(_BoundToModels, _Expression, Generic[ResourceT]): + """A SCIM attribute path, as defined at :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. + + A path *is* the string it was built from, and designates one of three + things: + + - a model, when the path is ``""`` (the resource root) or a bare schema + URN, accessible with the :attr:`model` property; + - an attribute, such as ``userName`` or ``name.familyName``, accessible + with the :meth:`resolve` method as an :class:`~scim2_models.AttributeBinding`; + - some values of a multi-valued attribute, when the path selects them as in + ``emails[type eq "work"]``: the :attr:`value_filter` property holds the + filter, and the :meth:`resolve` method still answers for the attribute + they belong to. + + Syntax is checked on creation. Resolving attribute names requires a model + to resolve them against, bound with a parameterised type such as + ``Path[User]``. The :meth:`get`, :meth:`set` and :meth:`delete` methods + read and write a resource through the path. + + On Python 3.14, a path can be written as a t-string, whose interpolated + values are quoted as :meth:`ScimFilter.quote ` + does. Another path, or a :class:`~scim2_models.ScimFilter`, is inserted + as it stands:: + + Path[Group](t"members[value eq {member_id}]") + """ + _ast: "PathNode | None" = None """The parsed path, kept once :attr:`ast` has computed it.""" @@ -234,13 +261,24 @@ def value_filter(self) -> "FilterNode | None": value_path = self._as_value_path() return None if value_path is None else value_path.val_filter - def _designated_model(self) -> type[BaseModel] | None: - """Return the model this path designates when it names no attribute. + @property + def model(self) -> type[BaseModel] | None: + """The model this path designates, when it names no attribute. + + The resource root designates the bound model, and a bare schema URN + the resource or the extension it is the schema of. A path naming an + attribute designates a model through none of them, and answers + :data:`None`: :meth:`resolve` tells which attribute it is. - The resource root designates the bound model, and a bare schema URN the - model it is the schema of, among the bound models and their extensions. - Neither can go through attribute resolution, since neither names an - attribute. + >>> from scim2_models import EnterpriseUser, Path, User + >>> Path[User]("").model is User + True + >>> Path[User[EnterpriseUser]]( + ... EnterpriseUser.__schema__ + ... ).model is EnterpriseUser + True + >>> Path[User]("userName").model is None + True """ if not self.__scim_models__: return None @@ -256,18 +294,16 @@ def _designated_model(self) -> type[BaseModel] | None: def resolve(self) -> "AttributeBinding | None": """Bind this path to the attribute it designates on the bound model. - This is the single resolution the model-aware properties are built on. - :returns: The resolved attribute, or :data:`None` when the path is unbound, designates a model rather than an attribute, or names an attribute the model does not declare. """ model = self._resolving_model() - if model is None or self._designated_model() is not None: + if model is None or self.model is not None: return None # A path that designates no attribute has already returned above, - # since _designated_model answers for the resource root. + # since the model property answers for the resource root. designated = self._designated_attr_path() assert designated is not None @@ -283,93 +319,6 @@ def models(self) -> tuple[type[BaseModel], ...]: """ return self.__scim_models__ - @property - def model(self) -> type[BaseModel] | None: - """The model holding the attribute this path designates. - - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound or invalid. - - For "name.familyName" on Path[User], returns Name. - For "userName" on Path[User], returns User. - """ - if (designated := self._designated_model()) is not None: - return designated - - resolved = self.resolve() - return resolved.target_model if resolved is not None else None - - @property - def field_name(self) -> str | None: - """The Python attribute name (snake_case) for this path. - - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound, invalid, or designates a model - rather than one of its attributes. - - For "name.familyName" on Path[User], returns "family_name". - For "userName" on Path[User], returns "user_name". - """ - resolved = self.resolve() - return resolved.target_field_name if resolved is not None else None - - @property - def field_type(self) -> type | None: - """The Python type of the field this path points to. - - Annotated types are unwrapped, so a ``binary`` attribute declared as - ``Base64Bytes`` reports :class:`bytes`. - - For "userName" on Path[User], returns str. - For "name" on Path[User], returns Name. - For "emails" on Path[User], returns Email. - """ - resolved = self.resolve() - return resolved.target_type if resolved is not None else None - - @property - def is_multivalued(self) -> bool | None: - """Whether this path points to a multi-valued attribute. - - For "emails" on Path[User], returns True. - For "emails.value" on Path[User], returns False, as the path - designates one value per entry. - """ - resolved = self.resolve() - return resolved.target_is_multivalued if resolved is not None else None - - def get_annotation(self, annotation_type: type) -> Any: - """Get annotation value for this path's field. - - :param annotation_type: The annotation class (e.g., Required, Mutability). - :returns: The annotation value, or None when the path designates no - attribute. - - For "userName" on Path[User] with Required, returns Required.true. - """ - resolved = self.resolve() - if resolved is None or resolved.target_model is None: - return None - return resolved.target_model.get_field_annotation( - resolved.target_field_name, annotation_type - ) - - @property - def urn(self) -> str | None: - """The fully qualified URN for this path. - - Requires the Path to be bound to a model type via ``Path[Model]``. - Returns None if the path is unbound or invalid. - - For "userName" on Path[User], returns - "urn:ietf:params:scim:schemas:core:2.0:User:userName". - """ - if (designated := self._designated_model()) is not None: - return getattr(designated, "__schema__", None) or None - - resolved = self.resolve() - return resolved.urn if resolved is not None else None - def _as_value_path(self) -> ValuePath | None: """Normalise a value-selecting path into a single representation. diff --git a/scim2_models/path/resolution.py b/scim2_models/path/resolution.py index b21ca7e8..ad4a48de 100644 --- a/scim2_models/path/resolution.py +++ b/scim2_models/path/resolution.py @@ -108,6 +108,22 @@ def target_is_multivalued(self) -> bool | None: return None return model.get_field_multiplicity(self.target_field_name) + def get_annotation(self, annotation_type: type) -> Any: + """Return an annotation of the attribute the path designates. + + The sub-attribute answers when the path names one, the head attribute + otherwise, so ``emails.value`` reports the annotations of ``value``. + + :param annotation_type: The annotation class, such as ``Mutability``. + :returns: The annotation value, or :data:`None` when the attribute + carries none, or when the head attribute holds no sub-attribute to + answer for. + """ + model = self.target_model + if model is None: + return None + return model.get_field_annotation(self.target_field_name, annotation_type) + def nested_in(self, urn: str) -> "AttributeBinding": """Return the same attribute, qualified by the URN it was resolved under. diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 3c74a0d0..f4a0b25e 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -261,7 +261,7 @@ def sorting_order(resources, attribute, sort_order=None): def sorting_key(resource, attribute): """Return the single value a ``sortBy`` orders a resource by.""" request = SearchRequest[User[EnterpriseUser]](sort_by=attribute) - return sort_value(resource, request.sort_by) + return sort_value(resource, request.sort_by.resolve()) @pytest.mark.parametrize("attribute", ["emails", "emails.value"]) diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index 2a84165b..afee5238 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -2888,7 +2888,7 @@ def test_a_model_built_at_runtime_is_collected_once_it_is_dropped(): # The assertions are made on values that do not name the model, since # pytest keeps the operands of an assertion for as long as the test runs. - bound = Path[Model]("label").field_name == "label" + bound = Path[Model]("label").resolve().target_field_name == "label" matched = ScimFilter[Model]('label eq "x"').match(Model(label="x")) assert bound assert matched diff --git a/tests/test_filter_semantics.py b/tests/test_filter_semantics.py index 31f9f3bb..4a1895af 100644 --- a/tests/test_filter_semantics.py +++ b/tests/test_filter_semantics.py @@ -983,6 +983,7 @@ def test_the_model_of_a_sub_attribute_on_a_simple_attribute_is_unknown(): ) assert resolved.target_model is None assert resolved.target_is_multivalued is None + assert resolved.get_annotation(Required) is None def test_an_extension_attribute_read_from_the_extension_itself(): diff --git a/tests/test_path.py b/tests/test_path.py index 6038a502..6716a297 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -73,13 +73,13 @@ def test_a_reference_sub_attribute_is_a_valid_path(): """RFC 7643 spells the reference of a complex attribute ``$ref``.""" path = Path[Group]("members.$ref") path.check_attribute_notation() - assert path.field_name == "ref" + assert path.resolve().target_field_name == "ref" assert path.attr == "members.$ref" qualified = Path[User[EnterpriseUser]]( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.$ref" ) - assert qualified.field_name == "ref" + assert qualified.resolve().target_field_name == "ref" group = Group( display_name="admins", @@ -158,129 +158,50 @@ class ModelWithPath(BaseModel): # --- Path bound to model tests --- -def test_model_simple_attribute(): - """Model property returns the target model for simple attribute.""" - path = Path[User]("userName") - assert path.model == User +def test_a_bound_path_resolves_to_the_attribute_it_designates(): + """The binding names the model holding the attribute, its field and its URN.""" + resolved = Path[User]("userName").resolve() + assert resolved.target_model is User + assert resolved.target_field_name == "user_name" + assert resolved.urn == "urn:ietf:params:scim:schemas:core:2.0:User:userName" -def test_model_complex_attribute(): - """Model property returns the nested model for complex attribute.""" - path = Path[User]("name.familyName") - assert path.model == Name - - -def test_model_extension_attribute(): - """Model property returns extension model for extension path.""" - path = Path[User[EnterpriseUser]]( - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" - ) - assert path.model == EnterpriseUser - - -def test_model_extension_complex_attribute(): - """Model property navigates into extension complex attributes.""" - path = Path[User[EnterpriseUser]]( - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager.value" - ) - assert path.model == Manager - - -def test_model_invalid_attribute(): - """Model property returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.model is None - - -def test_model_unbound_path(): - """Model property returns None for unbound path.""" - path = Path("userName") - assert path.model is None - - -def test_field_name_simple_attribute(): - """field_name property returns snake_case field name.""" - path = Path[User]("userName") - assert path.field_name == "user_name" - - -def test_field_name_complex_attribute(): - """field_name property returns snake_case for nested attribute.""" - path = Path[User]("name.familyName") - assert path.field_name == "family_name" - - -def test_field_name_extension_attribute(): - """field_name property works for extension attributes.""" - path = Path[User[EnterpriseUser]]( - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" - ) - assert path.field_name == "employee_number" - - -def test_field_name_invalid_attribute(): - """field_name property returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.field_name is None - - -def test_field_name_unbound_path(): - """field_name property returns None for unbound path.""" - path = Path("userName") - assert path.field_name is None - - -def test_field_name_schema_only(): - """field_name property returns None for schema-only path.""" - path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") - assert path.field_name is None - +def test_a_sub_attribute_resolves_to_the_complex_type_holding_it(): + resolved = Path[User]("name.familyName").resolve() + assert resolved.target_model is Name + assert resolved.target_field_name == "family_name" + assert resolved.urn == "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName" -def test_urn_simple_attribute(): - """URN property returns fully qualified URN.""" - path = Path[User]("userName") - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:userName" +def test_an_extension_attribute_resolves_to_the_extension_declaring_it(): + enterprise = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + resolved = Path[User[EnterpriseUser]](f"{enterprise}:employeeNumber").resolve() + assert resolved.target_model is EnterpriseUser + assert resolved.target_field_name == "employee_number" + assert resolved.urn == f"{enterprise}:employeeNumber" -def test_urn_complex_attribute(): - """URN property includes dotted path.""" - path = Path[User]("name.familyName") - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName" + nested = Path[User[EnterpriseUser]](f"{enterprise}:manager.value").resolve() + assert nested.target_model is Manager + assert nested.target_field_name == "value" -def test_urn_already_qualified(): - """URN property preserves already qualified paths.""" +def test_a_qualified_path_resolves_to_the_urn_it_spells(): path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User:userName") - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:userName" + assert path.resolve().urn == str(path) -def test_urn_extension_attribute(): - """URN property works for extension attributes.""" - path = Path[User[EnterpriseUser]]( - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" - ) - assert ( - path.urn - == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" - ) - - -def test_urn_invalid_attribute(): - """URN property returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.urn is None - - -def test_urn_unbound_path(): - """URN property returns None for unbound path.""" - path = Path("userName") - assert path.urn is None - - -def test_urn_schema_only(): - """URN property returns schema for schema-only path.""" - path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User" +@pytest.mark.parametrize( + "path", + [ + "invalidAttribute", + "invalidAttr.familyName", + "userName.subField", + "name.invalidField", + ], +) +def test_a_path_naming_an_unknown_attribute_resolves_to_nothing(path): + """A missing head, a sub-attribute of a scalar, a missing sub-attribute alike.""" + assert Path[User](path).resolve() is None def test_path_caching(): @@ -430,8 +351,7 @@ def test_a_foreign_schema_urn_designates_nothing(): """A qualified path is expressed in the schema of the model it applies to.""" path = Path[EnterpriseUser]("urn:totally:unrelated:employeeNumber") assert path.model is None - assert path.field_name is None - assert path.urn is None + assert path.resolve() is None def test_an_extension_urn_may_extend_the_urn_of_the_resource_it_extends(): @@ -455,9 +375,10 @@ class Thing(Resource): thing[Ext] = Ext(label="labelled") path = Path[Thing[Ext]]("urn:example:2.0:Thing:Ext:label") - assert path.model is Ext - assert path.field_name == "label" - assert path.urn == "urn:example:2.0:Thing:Ext:label" + resolved = path.resolve() + assert resolved.target_model is Ext + assert resolved.target_field_name == "label" + assert resolved.urn == "urn:example:2.0:Thing:Ext:label" assert path.get(thing) == "labelled" assert path.set(thing, "renamed") is True assert thing[Ext].label == "renamed" @@ -841,7 +762,7 @@ def test_iter_paths_returns_bound_paths(): paths = list(Path[User[EnterpriseUser]].iter_paths(include_subattributes=False)) user_name_path = next(p for p in paths if str(p) == "userName") - assert user_name_path.model == User[EnterpriseUser] + assert user_name_path.resolve().target_model is User[EnterpriseUser] ext_path = next( p @@ -849,7 +770,7 @@ def test_iter_paths_returns_bound_paths(): if str(p) == "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" ) - assert ext_path.model == EnterpriseUser + assert ext_path.resolve().target_model is EnterpriseUser def test_iter_paths_without_extensions(): @@ -873,7 +794,7 @@ def test_iter_paths_spells_a_reference_sub_attribute_as_ref(): group_paths = list(Path[Group].iter_paths()) assert "members.$ref" in group_paths assert "members.ref" not in group_paths - assert all(path.field_name is not None for path in group_paths) + assert all(path.resolve() is not None for path in group_paths) user_paths = list(Path[User[EnterpriseUser]].iter_paths()) assert "groups.$ref" in user_paths @@ -895,30 +816,29 @@ def test_a_path_binds_to_a_union_of_resource_types(): def test_a_union_path_resolves_against_the_first_type_declaring_it(): - assert Path[User | Group]("userName").model is User - assert Path[User | Group]("userName").field_name == "user_name" - assert Path[User | Group]("members").model is Group - assert Path[User | Group]("members").field_name == "members" + assert Path[User | Group]("userName").resolve().target_model is User + assert Path[User | Group]("userName").resolve().target_field_name == "user_name" + assert Path[User | Group]("members").resolve().target_model is Group + assert Path[User | Group]("members").resolve().target_field_name == "members" assert ( - Path[User | Group]("displayName").urn + Path[User | Group]("displayName").resolve().urn == "urn:ietf:params:scim:schemas:core:2.0:User:displayName" ) assert ( - Path[User | Group]("members.$ref").urn + Path[User | Group]("members.$ref").resolve().urn == "urn:ietf:params:scim:schemas:core:2.0:Group:members.$ref" ) schema_only = Path[User | Group]("urn:ietf:params:scim:schemas:core:2.0:Group") assert schema_only.model is Group - assert schema_only.field_name is None + assert schema_only.resolve() is None def test_a_union_path_answers_none_for_an_attribute_no_type_declares(): path = Path[User | Group]("nonexistent") assert path.model is None - assert path.field_name is None - assert path.urn is None + assert path.resolve() is None def test_a_union_path_reads_an_attribute_the_resource_declares(): @@ -1167,12 +1087,12 @@ def test_attr_schema_only_path(): """A schema-only path reads as the last name of its own URN. Nothing tells one from a qualified path, the URN of a schema being itself a - colon-separated name, and :attr:`~scim2_models.Path.urn` puts the two halves - back together either way. + colon-separated name, and only :attr:`~scim2_models.Path.model` tells the + schema from the attribute. """ path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") assert path.attr == "User" - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User" + assert path.model is User def test_attr_empty_path(): @@ -1230,42 +1150,27 @@ def test_parts_value_filter_is_not_a_segment(): # --- Bound path resolution edge cases --- -def test_model_extension_schema_only_path(): - """Model property returns extension for schema-only extension path.""" +def test_a_bare_extension_urn_designates_the_extension(): + """The path names the extension itself, so it resolves to no attribute.""" path = Path[User[EnterpriseUser]]( "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" ) - assert path.model == EnterpriseUser - assert path.field_name is None + assert path.model is EnterpriseUser + assert path.resolve() is None -def test_model_urn_unknown_extension(): - """Model property returns None for URN not matching any extension.""" +def test_an_unknown_extension_urn_designates_nothing(): path = Path[User[EnterpriseUser]]( "urn:ietf:params:scim:schemas:extension:unknown:2.0:User:field" ) assert path.model is None + assert path.resolve() is None -def test_model_dotted_path_invalid_intermediate(): - """Model property returns None when intermediate part is invalid.""" - path = Path[User]("invalidAttr.familyName") - assert path.model is None - - -def test_model_dotted_path_intermediate_not_complex(): - """Model property returns None when intermediate type is not a complex attribute.""" - path = Path[User]("userName.subField") - assert path.model is None - - -def test_urn_empty_path_non_resource(): - """URN property handles empty path on non-Resource model.""" - path = Path[Name]("") - assert path.urn is None - - -# --- get() edge cases --- +def test_the_root_of_a_complex_attribute_designates_it(): + """A complex attribute is a model too, and its root names it.""" + assert Path[Name]("").model is Name + assert Path[Name]("").resolve() is None def test_get_extension_attribute_when_extension_is_none(): @@ -1431,12 +1336,6 @@ def test_delete_extension_already_none(): assert result is False -def test_model_dotted_path_invalid_last_part(): - """Model property returns None when last part of dotted path is invalid.""" - path = Path[User]("name.invalidField") - assert path.model is None - - def test_get_schema_only_path_returns_resource(): """Get with schema-only path returns the resource itself.""" user = User(user_name="john", display_name="John") @@ -1512,9 +1411,10 @@ def test_delete_dotted_path_intermediate_none(): def test_model_extension_bound_with_mismatched_urn(): - """Model property on Extension-bound path with unrelated URN.""" + """A path bound to an extension designates nothing under a foreign URN.""" path = Path[EnterpriseUser]("urn:ietf:params:scim:schemas:core:2.0:User:userName") assert path.model is None + assert path.resolve() is None def test_iter_paths_on_extension(): @@ -1532,116 +1432,25 @@ def test_iter_paths_on_extension(): ) -# --- field_type property tests --- - - -def test_field_type_simple_attribute(): - """field_type returns the Python type for simple attribute.""" - path = Path[User]("userName") - assert path.field_type is str - - -def test_field_type_complex_attribute(): - """field_type returns the complex attribute class.""" - path = Path[User]("name") - assert path.field_type is Name - - -def test_field_type_multivalued_attribute(): - """field_type returns the element type for multi-valued attributes.""" - path = Path[User]("emails") - assert path.field_type is Email - - -def test_field_type_nested_attribute(): - """field_type returns the type for nested attribute.""" - path = Path[User]("name.familyName") - assert path.field_type is str - - -def test_field_type_invalid_attribute(): - """field_type returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.field_type is None - - -def test_field_type_unbound_path(): - """field_type returns None for unbound path.""" - path = Path("userName") - assert path.field_type is None - - -def test_field_type_schema_only_path(): - """field_type returns None for schema-only path.""" - path = Path[User]("urn:ietf:params:scim:schemas:core:2.0:User") - assert path.field_type is None - - -# --- is_multivalued property tests --- - - -def test_is_multivalued_true(): - """is_multivalued returns True for multi-valued attribute.""" - path = Path[User]("emails") - assert path.is_multivalued is True +# --- What the binding of a path reports --- -def test_is_multivalued_false(): - """is_multivalued returns False for single-valued attribute.""" - path = Path[User]("userName") - assert path.is_multivalued is False - - -def test_is_multivalued_nested_attribute(): - """is_multivalued works for nested attributes.""" - path = Path[User]("name.familyName") - assert path.is_multivalued is False - - -def test_is_multivalued_invalid_attribute(): - """is_multivalued returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.is_multivalued is None - - -def test_is_multivalued_unbound_path(): - """is_multivalued returns None for unbound path.""" - path = Path("emails") - assert path.is_multivalued is None +def test_the_binding_reports_the_python_type_of_the_attribute(): + """A multi-valued attribute reports the type of its entries.""" + assert Path[User]("userName").resolve().target_type is str + assert Path[User]("name").resolve().target_type is Name + assert Path[User]("emails").resolve().target_type is Email + assert Path[User]("name.familyName").resolve().target_type is str -# --- get_annotation() method tests --- +def test_the_binding_answers_the_annotations_of_the_attribute(): + """A sub-attribute answers for itself, not for the attribute holding it.""" + resolved = Path[User]("userName").resolve() + assert resolved.get_annotation(Required) is Required.true + assert resolved.get_annotation(Mutability) is Mutability.read_write - -def test_get_annotation_required(): - """get_annotation returns Required annotation value.""" - path = Path[User]("userName") - assert path.get_annotation(Required) == Required.true - - -def test_get_annotation_mutability(): - """get_annotation returns Mutability annotation value.""" - path = Path[User]("userName") - assert path.get_annotation(Mutability) == Mutability.read_write - - -def test_get_annotation_nested_attribute(): - """get_annotation works for nested attributes.""" - path = Path[User]("name.familyName") - result = path.get_annotation(Required) - assert result is None or isinstance(result, Required) - - -def test_get_annotation_invalid_attribute(): - """get_annotation returns None for invalid attribute.""" - path = Path[User]("invalidAttribute") - assert path.get_annotation(Required) is None - - -def test_get_annotation_unbound_path(): - """get_annotation returns None for unbound path.""" - path = Path("userName") - assert path.get_annotation(Required) is None + nested = Path[User]("name.familyName").resolve() + assert nested.get_annotation(Mutability) is Mutability.read_write # --- iter_paths() with filters tests --- @@ -1656,7 +1465,7 @@ def test_iter_paths_filter_by_required(): assert "userName" in path_strings for path in paths: - assert path.get_annotation(Required) == Required.true + assert path.resolve().get_annotation(Required) == Required.true def test_iter_paths_filter_by_mutability(): @@ -1668,7 +1477,7 @@ def test_iter_paths_filter_by_mutability(): ) for path in paths: - assert path.get_annotation(Mutability) == Mutability.read_only + assert path.resolve().get_annotation(Mutability) == Mutability.read_only def test_iter_paths_filter_by_required_and_mutability(): @@ -1682,8 +1491,8 @@ def test_iter_paths_filter_by_required_and_mutability(): ) for path in paths: - assert path.get_annotation(Required) == Required.true - assert path.get_annotation(Mutability) == Mutability.read_write + assert path.resolve().get_annotation(Required) == Required.true + assert path.resolve().get_annotation(Mutability) == Mutability.read_write def test_iter_paths_filter_includes_subattributes(): @@ -1693,7 +1502,7 @@ def test_iter_paths_filter_includes_subattributes(): ) for path in paths: - assert path.get_annotation(Required) == Required.true + assert path.resolve().get_annotation(Required) == Required.true def test_iter_paths_filter_no_match(): @@ -1720,7 +1529,7 @@ def test_iter_paths_filter_excludes_subattributes(): assert len(paths_with_filter) < len(paths_without_filter) for path in paths_with_filter: - assert path.get_annotation(Required) == Required.true + assert path.resolve().get_annotation(Required) == Required.true def test_iter_paths_filter_skips_non_matching_subattributes(): @@ -1754,7 +1563,7 @@ def test_iter_paths_filter_by_uniqueness(): assert "userName" in path_strings for path in paths: - assert path.get_annotation(Uniqueness) == Uniqueness.server + assert path.resolve().get_annotation(Uniqueness) == Uniqueness.server def test_iter_paths_filter_by_returned(): @@ -1766,7 +1575,7 @@ def test_iter_paths_filter_by_returned(): assert "password" in path_strings for path in paths: - assert path.get_annotation(Returned) == Returned.never + assert path.resolve().get_annotation(Returned) == Returned.never def test_iter_paths_filter_by_case_exact(): @@ -1778,7 +1587,7 @@ def test_iter_paths_filter_by_case_exact(): assert "externalId" in path_strings for path in paths: - assert path.get_annotation(CaseExact) == CaseExact.true + assert path.resolve().get_annotation(CaseExact) == CaseExact.true def test_path_init_with_path_object(): @@ -1823,9 +1632,12 @@ def test_a_schema_urn_carries_an_attribute_behind_a_colon(): URN that merely starts like it. """ assert ( - Path[User]("urn:ietf:params:scim:schemas:core:2.0:User:id").field_name == "id" + Path[User]("urn:ietf:params:scim:schemas:core:2.0:User:id") + .resolve() + .target_field_name + == "id" ) - assert Path[User]("urn:ietf:params:scim:schemas:core:2.0:UserId").field_name is None + assert Path[User]("urn:ietf:params:scim:schemas:core:2.0:UserId").resolve() is None def test_an_extension_urn_carries_an_attribute_behind_a_colon(): @@ -1851,42 +1663,46 @@ def test_resolving_a_path_binds_it_to_the_attribute_it_designates(): def test_a_path_designating_a_model_rather_than_an_attribute_resolves_to_nothing(): - """The resource root and a bare schema URN both name a model.""" + """The resource root and a bare schema URN both name a model, an attribute path none.""" assert Path[User]("").resolve() is None assert Path[User]("").model is User - assert Path[User]("").field_name is None extension_urn = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" schema_only = Path[User[EnterpriseUser]](extension_urn) assert schema_only.resolve() is None assert schema_only.model is EnterpriseUser - assert schema_only.field_name is None + + assert Path[User]("userName").model is None + assert Path[User]("name.familyName").model is None def test_an_unbound_or_unknown_path_resolves_to_nothing(): assert Path("userName").resolve() is None assert Path[User]("nonexistent").resolve() is None - assert Path[User]("nonexistent").model is None + assert Path("").model is None + assert Path("userName").model is None def test_a_path_bound_to_a_complex_attribute_resolves_its_sub_attributes(): """A complex attribute is a model too, though it carries no schema.""" - path = Path[Name]("familyName") - assert path.model is Name - assert path.field_name == "family_name" - assert path.field_type is str - assert path.get_annotation(Mutability) == Mutability.read_write + resolved = Path[Name]("familyName").resolve() + assert resolved.target_model is Name + assert resolved.target_field_name == "family_name" + assert resolved.target_type is str + assert resolved.get_annotation(Mutability) is Mutability.read_write def test_the_type_of_an_annotated_attribute_is_unwrapped(): """A ``binary`` attribute declared as Base64Bytes reports bytes.""" - assert Path[User]("x509Certificates.value").field_type is bytes + assert Path[User]("x509Certificates.value").resolve().target_type is bytes def test_a_sub_attribute_of_a_multivalued_attribute_is_not_multivalued(): """``emails`` holds several values, ``emails.value`` designates one per entry.""" - assert Path[User]("emails").is_multivalued is True - assert Path[User]("emails.value").is_multivalued is False + assert Path[User]("emails").resolve().target_is_multivalued is True + assert Path[User]("emails.value").resolve().target_is_multivalued is False + assert Path[User]("userName").resolve().target_is_multivalued is False + assert Path[User]("name.familyName").resolve().target_is_multivalued is False @pytest.mark.parametrize( @@ -1897,24 +1713,23 @@ def test_a_sub_attribute_of_a_multivalued_attribute_is_not_multivalued(): pytest.param(Group, id="Group"), ], ) -def test_every_enumerated_path_agrees_with_its_resolution(model): - """The model-aware properties agree with ``resolve`` over a whole model. +def test_every_enumerated_path_resolves_or_names_a_schema(model): + """Every path ``iter_paths`` yields designates an attribute or a schema. - They all read that single resolution, so this covers the full attribute - surface of a resource and its extensions at once, and breaks if a second - mechanism or a diverging convention creeps back in. + This covers the full attribute surface of a resource and its extensions at + once, and breaks if the enumeration and the resolution stop agreeing. """ paths = list(Path[model].iter_paths()) assert paths for path in paths: - if path.resolve() is None: - # A bare schema URN names a model rather than one of its attributes. + resolved = path.resolve() + if resolved is None: assert path.model is not None - assert path.field_name is None continue - assert path.field_name in path.model.model_fields - assert path.field_type is not None - assert path.is_multivalued is not None - assert path.urn.endswith(path.attr) + assert path.model is None + assert resolved.target_field_name in resolved.target_model.model_fields + assert resolved.target_type is not None + assert resolved.target_is_multivalued is not None + assert resolved.urn.endswith(path.attr) diff --git a/tests/test_path_value_selection.py b/tests/test_path_value_selection.py index 9e880d8a..72d28134 100644 --- a/tests/test_path_value_selection.py +++ b/tests/test_path_value_selection.py @@ -83,7 +83,9 @@ def test_a_value_selecting_path_designates_an_attribute(user): assert path.schema == "urn:ietf:params:scim:schemas:core:2.0:User" assert path.attr == "emails.value" assert path.parts == ("emails", "value") - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + assert ( + path.resolve().urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + ) # --- Reading --- @@ -409,14 +411,13 @@ def test_a_selection_naming_an_unknown_attribute_is_silent_when_tolerant(user): def test_every_notation_resolves_against_the_model(path, field_name): """The three notations designate an attribute, so they all resolve. - :attr:`~scim2_models.Path.model` and its siblings used to answer ``None`` - for anything but a plain attribute path, since they read the rendered - expression instead of the attribute it designates. + Reading the rendered expression instead of the attribute it designates used + to resolve nothing but a plain attribute path. """ - resolved = Path[User](path) - assert resolved.field_name == field_name - assert resolved.model is not None - assert resolved.field_type is not None + resolved = Path[User](path).resolve() + assert resolved is not None + assert resolved.target_field_name == field_name + assert resolved.target_type is not None assert resolved.get_annotation(Mutability) is not None @@ -433,11 +434,13 @@ def test_the_two_spellings_of_one_selection_designate_the_same_attribute(user): assert bare.get(user) == bracketed.get(user) assert bare.parts == bracketed.parts == ("emails",) assert bare.attr == bracketed.attr == "emails" - assert bare.model is bracketed.model is User - assert bare.field_name == bracketed.field_name == "emails" - assert bare.field_type is bracketed.field_type is Email - assert bare.is_multivalued is bracketed.is_multivalued is True - assert bare.urn == bracketed.urn == f"{User.__schema__}:emails" + resolved = bracketed.resolve() + assert bare.resolve() == resolved + assert resolved.target_model is User + assert resolved.target_field_name == "emails" + assert resolved.target_type is Email + assert resolved.target_is_multivalued is True + assert resolved.urn == f"{User.__schema__}:emails" def test_the_annotations_of_a_bare_comparison_come_from_the_attribute_it_selects(): @@ -449,19 +452,19 @@ def test_the_annotations_of_a_bare_comparison_come_from_the_attribute_it_selects bracketed = Path[Group]('members[value eq "u1"]') bare = Path[Group]('members.value eq "u1"') - assert bare.get_annotation(CaseExact) is bracketed.get_annotation(CaseExact) - assert bracketed.get_annotation(CaseExact) is CaseExact.false + assert bare.resolve().get_annotation(CaseExact) is CaseExact.false + assert bracketed.resolve().get_annotation(CaseExact) is CaseExact.false def test_a_selection_designates_the_sub_attribute_it_targets(): - path = Path[User]('emails[type eq "work"].value') - assert path.model is Email - assert path.field_name == "value" - assert path.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" + resolved = Path[User]('emails[type eq "work"].value').resolve() + assert resolved.target_model is Email + assert resolved.target_field_name == "value" + assert resolved.urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.value" def test_a_selection_without_a_sub_attribute_designates_the_attribute_itself(): - path = Path[User]('emails[type eq "work"]') - assert path.model is User - assert path.field_name == "emails" - assert path.is_multivalued is True + resolved = Path[User]('emails[type eq "work"]').resolve() + assert resolved.target_model is User + assert resolved.target_field_name == "emails" + assert resolved.target_is_multivalued is True diff --git a/tests/test_search_request.py b/tests/test_search_request.py index 6b116ed2..4e7891c5 100644 --- a/tests/test_search_request.py +++ b/tests/test_search_request.py @@ -348,7 +348,7 @@ def test_a_parameterised_request_declares_the_extensions_it_serves(): def test_a_parameterised_request_resolves_its_sort_by(): request = SearchRequest[User].model_validate({"sortBy": "userName"}) - assert request.sort_by.field_name == "user_name" + assert request.sort_by.resolve().target_field_name == "user_name" def test_an_unparameterised_request_only_checks_the_filter_syntax(): @@ -357,7 +357,7 @@ def test_an_unparameterised_request_only_checks_the_filter_syntax(): assert request.filter == 'nonexistent eq "x"' assert request.filter.model is None assert ( - SearchRequest.model_validate({"sortBy": "userName"}).sort_by.field_name is None + SearchRequest.model_validate({"sortBy": "userName"}).sort_by.resolve() is None ) @@ -374,8 +374,8 @@ def test_a_request_covering_several_resource_types_takes_a_union(): assert request.filter.match(User(user_name="bjensen")) assert request.filter.match(Group(display_name="admins", members=[{"value": "u1"}])) assert request.sort_by.models == (User, Group) - assert request.sort_by.field_name == "user_name" - assert request.attributes[0].model is Group + assert request.sort_by.resolve().target_field_name == "user_name" + assert request.attributes[0].resolve().target_model is Group def test_a_parameterised_request_rejects_a_sort_by_the_model_does_not_declare(): @@ -401,7 +401,7 @@ def test_a_parameterised_request_rejects_a_sort_by_designating_a_resource(): def test_a_sort_by_on_a_union_answers_to_the_type_declaring_it(): """§3.4.2.1 has a root query cover types that do not share every attribute.""" request = SearchRequest[User | Group].model_validate({"sortBy": "members"}) - assert request.sort_by.model is Group + assert request.sort_by.resolve().target_model is Group with pytest.raises(ValidationError) as raised: SearchRequest[User | Group].model_validate({"sortBy": "nonexistent"}) @@ -423,8 +423,8 @@ def test_a_union_request_rejects_an_attribute_no_resource_type_declares(): def test_a_union_request_resolves_its_sort_by(): """A root query sorts on an attribute only some of the types it serves declare.""" request = SearchRequest[User | Group].model_validate({"sortBy": "userName"}) - assert request.sort_by.field_name == "user_name" - assert request.sort_by.model is User + assert request.sort_by.resolve().target_field_name == "user_name" + assert request.sort_by.resolve().target_model is User def test_a_parameterised_request_resolves_its_attributes(): @@ -432,19 +432,25 @@ def test_a_parameterised_request_resolves_its_attributes(): request = SearchRequest[User].model_validate( {"attributes": ["userName", "emails.value"]} ) - assert [path.field_name for path in request.attributes] == ["user_name", "value"] - assert [path.model.__name__ for path in request.attributes] == ["User", "Email"] + assert [path.resolve().target_field_name for path in request.attributes] == [ + "user_name", + "value", + ] + assert [path.resolve().target_model.__name__ for path in request.attributes] == [ + "User", + "Email", + ] def test_a_union_request_resolves_its_attributes(): request = SearchRequest[User | Group].model_validate( {"attributes": ["userName", "members"]} ) - assert [path.model for path in request.attributes] == [User, Group] + assert [path.resolve().target_model for path in request.attributes] == [User, Group] def test_an_unparameterised_request_leaves_its_attributes_unresolved(): """§3.9 makes no promise about an attribute a resource type does not declare.""" request = SearchRequest.model_validate({"attributes": "userName"}) assert request.attributes == ["userName"] - assert request.attributes[0].field_name is None + assert request.attributes[0].resolve() is None