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..dc38f966 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -6,26 +6,55 @@ 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` +- 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` 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`, + :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.AttributeBinding` it designates. +- lark is a new dependency. 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 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. 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/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 new file mode 100644 index 00000000..b382da36 --- /dev/null +++ b/doc/filters.rst @@ -0,0 +1,438 @@ +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.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.path 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.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:: + + >>> 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.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.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. + +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.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.path 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'] + +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>`. The :doc:`guides/sqlalchemy` guide implements +them. + +.. _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.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. + +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. + The :doc:`guides/sqlalchemy` guide shows where. + +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.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 + 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..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." @@ -179,7 +177,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 +194,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 +262,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/_examples/sqlalchemy_example.py b/doc/guides/_examples/sqlalchemy_example.py new file mode 100644 index 00000000..dab57016 --- /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.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 + + +# -- 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/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..a9602d56 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 @@ -44,19 +75,22 @@ 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 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. @@ -90,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..5a51a2f5 --- /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.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 + 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/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..e56c1d8c 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: + +Paths and filters +================= + +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.path + :members: + :exclude-members: Path, AttributeBinding, ScimFilter + +.. currentmodule:: scim2_models.path + +.. 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/pyproject.toml b/pyproject.toml index e57ddb1a..96f0af3a 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] @@ -50,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 = [ @@ -79,6 +81,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 @@ -113,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/scim2_models/__init__.py b/scim2_models/__init__.py index 3badf89d..5c71109b 100644 --- a/scim2_models/__init__.py +++ b/scim2_models/__init__.py @@ -44,7 +44,9 @@ 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 ScimFilter from .reference import URI from .reference import External from .reference import Reference @@ -90,6 +92,7 @@ "AnyResource", "AnyScimObject", "Attribute", + "AttributeBinding", "AuthenticationScheme", "BaseModel", "Bulk", @@ -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/messages/patch_op.py b/scim2_models/messages/patch_op.py index 9676cac0..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 ..resources.resource import Extension +from ..path 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/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 48d4a1e6..1123bd2b 100644 --- a/scim2_models/messages/search_request.py +++ b/scim2_models/messages/search_request.py @@ -4,9 +4,11 @@ from pydantic import field_validator +from ..exceptions import InvalidFilterException from ..exceptions import InvalidPathException 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 @@ -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 @@ -60,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.py b/scim2_models/path.py deleted file mode 100644 index 06c66841..00000000 --- a/scim2_models/path.py +++ /dev/null @@ -1,918 +0,0 @@ -import re -from collections.abc import Iterable -from collections.abc import Iterator -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 NamedTuple -from typing import TypeVar -from typing import cast -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 .urn import URN -from .utils import _find_field_name -from .utils import _model_union -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 InvalidPathException -from .exceptions import PathNotFoundException - -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() -) -"""The classes subscription has already built, so that two subscriptions of the -same resource type 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() - 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 _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) - - -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. - - :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. - """ - 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 - - -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 Path(str, Generic[ResourceT]): - __scim_models__: tuple[type[BaseModel], ...] = () - - 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 - - @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]") -> "Path[Any]": - cls.check_syntax(str(path)) - return super().__new__(cls, path) - - @classmethod - def check_syntax(cls, path: str) -> None: - """Check if path syntax is valid according to RFC 7644 simplified rules. - - An empty string is valid and represents the resource root. - - :param path: The path to validate - :raises InvalidPathException: If the path syntax is invalid - """ - if not path: - return - - if path[0].isdigit(): - raise InvalidPathException( - path=path, detail="Paths cannot start with a digit" - ) - - if ".." in path: - raise InvalidPathException( - path=path, detail="Paths cannot contain double dots" - ) - - if not _VALID_PATH_PATTERN.match(path): - raise InvalidPathException( - path=path, detail="The path contains invalid characters" - ) - - if path.endswith(":"): - raise InvalidPathException( - path=path, detail="Paths cannot end with a colon" - ) - - 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 - - def check_attribute_notation(self) -> None: - """Check that the path names an attribute instead of selecting values. - - The attribute notation of :rfc:`RFC7644 §3.10 <7644#section-3.10>` is a - schema URN, an attribute and at most one of its sub-attributes. A value - selection or a comparison designates the values an attribute holds - rather than the attribute itself, so it has no place where a single - attribute is asked for. - - :raises InvalidPathException: If the path is not in attribute notation. - """ - if not _ATTRIBUTE_NOTATION_PATTERN.match(self): - raise InvalidPathException( - path=str(self), - detail=f"{str(self)!r} is not in attribute notation", - ) - - @property - def schema(self) -> str | None: - """The schema URN portion of the path. - - 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] - - @property - def attr(self) -> str: - """The attribute portion of the path. - - For paths like "urn:...:User:userName", returns "userName". - For simple paths like "userName", returns "userName". - 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". - """ - if ":" not in self: - return str(self) - return self.rsplit(":", 1)[1] - - @property - def parts(self) -> tuple[str, ...]: - """The attribute path segments split by '.'. - - For "name.familyName", returns ("name", "familyName"). - For "userName", returns ("userName",). - For "", returns (). - """ - attr = self.attr - if not attr: - return () - return tuple(attr.split(".")) - - @property - def models(self) -> tuple[type[BaseModel], ...]: - """The resource types the path is bound to, none for an unbound path. - - ``Path[User]`` binds one, ``Path[User | Group]`` binds two, as an - endpoint covering several resource types does. A path is resolved - against the first of them declaring the attribute it names. - """ - return self.__scim_models__ - - @property - def model(self) -> type[BaseModel] | None: - """The target model type 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 "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] - - @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. - - 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] - - @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. - - 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) - - @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. - """ - if self.model is None or self.field_name is None: - return None - return self.model.get_field_multiplicity(self.field_name) - - 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. - - For "userName" on Path[User] with Required, returns Required.true. - """ - if self.model is None or self.field_name is None: - return None - return self.model.get_field_annotation(self.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". - """ - 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(":") - - return model, attr_path - - 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 - 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: - return None - return model, field_name - - def _resolve_instance( - 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. - """ - from .resources.resource import Extension - from .resources.resource import Resource - - path_str = str(self) - - if ":" not in path_str: - return _Resolution(resource, path_str) - - 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 {} - ) - - schema = _designating_schema(path_lower, [model_schema, *extension_models]) - if schema is None: - if isinstance(resource, Resource): - raise InvalidPathException(path=path_str) - return None - - sub_path = path_str[len(schema) :].lstrip(":") - ext_model = extension_models.get(schema) - - if ext_model is None: - return _Resolution(resource, sub_path, path_lower == schema.lower()) - - if path_lower == schema.lower(): - return _Resolution(resource, ext_model.__name__) - - 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: - 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) - - @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 _get(self, resource: ResourceT) -> Any: - """Get the value at this path from a resource.""" - 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: - return None - - 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. - - A path crossing a multi-valued attribute designates the sub-attribute of - each of its entries, so ``emails.value`` answers with one item per email, - in their order, :data:`None` included for an email carrying no value. - Writing through such a path reaches those same entries, which is what - makes the answer their mirror. An attribute holding no entry at all - answers :data:`None`, as any unassigned attribute does. - - :param resource: The resource to get the value from. - :param strict: If True, raise exceptions for invalid paths. - :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. - """ - try: - return self._get(resource) - except InvalidPathException: - if strict: - raise - return None - - def _set(self, resource: ResourceT, value: Any, *, is_add: bool = False) -> bool: - """Set a value at this path on a resource.""" - 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: - return False - - changed = [ - self._set_field_value(host, target.field_name, value, is_add) - for host in target.hosts - ] - return any(changed) - - def set( - self, - resource: ResourceT, - value: Any, - *, - is_add: bool = False, - strict: bool = True, - ) -> bool: - """Set a value at this path on a resource. - - A path crossing a multi-valued attribute writes the sub-attribute of each - of its entries, so ``emails.value`` gives every email the same value. An - unassigned multi-valued attribute has no entry to write to, and is left - alone. - - :param resource: The resource to set the value on. - :param value: The value to set. - :param is_add: If True and the target is multi-valued, append to the - list instead of replacing. Duplicates are not added. - :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. - """ - try: - return self._set(resource, value, is_add=is_add) - except InvalidPathException: - 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 (resolution := self._resolve_instance(resource)) is None: - return False - - if not resolution.path_str: - 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 - ] - 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: - """Delete a value at this path from a resource. - - If value is None, the entire attribute is set to None. - If value is provided and the attribute is multi-valued, - only matching values are removed from the list. - A path crossing a multi-valued attribute removes the sub-attribute from - each of its entries, so ``emails.type`` leaves the emails in place and - untypes them. - - :param resource: The resource to delete the value from. - :param value: Optional specific value to remove from a list. - :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. - """ - try: - return self._delete(resource, value) - except InvalidPathException: - if strict: - raise - return False - - @classmethod - def iter_paths( - cls, - include_subattributes: bool = True, - include_extensions: bool = True, - required: "list[Required] | None" = None, - mutability: "list[Mutability] | None" = None, - uniqueness: "list[Uniqueness] | None" = None, - returned: "list[Returned] | None" = None, - case_exact: "list[CaseExact] | None" = None, - ) -> "Iterator[Path[ResourceT]]": - """Iterate over all paths for the bound model and its extensions. - - Requires the Path to be bound to a model type via ``Path[Model]``. - - :param include_subattributes: Whether to include sub-attribute paths. - :param include_extensions: Whether to include extension attributes. - :param required: Filter by Required annotation values (e.g., [Required.true]). - :param mutability: Filter by Mutability annotation values (e.g., [Mutability.read_write]). - :param uniqueness: Filter by Uniqueness annotation values (e.g., [Uniqueness.server]). - :param returned: Filter by Returned annotation values (e.g., [Returned.always]). - :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 - - if len(cls.__scim_models__) != 1: - raise TypeError( - "iter_paths requires a Path bound to one model: Path[Model]" - ) - model = cls.__scim_models__[0] - - selected = ( - (required, Required), - (mutability, Mutability), - (uniqueness, Uniqueness), - (returned, Returned), - (case_exact, CaseExact), - ) - - def matches_filters(target_model: type[BaseModel], field_name: str) -> bool: - return all( - target_model.get_field_annotation(field_name, annotation) in values - for values, annotation in selected - if values is not None - ) - - def iter_model_paths( - target_model: type[Resource[Any] | Extension], - ) -> "Iterator[Path[ResourceT]]": - for field_name in target_model.model_fields: - if field_name in ("meta", "id", "schemas"): - continue - - if not matches_filters(target_model, field_name): - continue - - field_type = target_model.get_field_root_type(field_name) - - urn: str - if isclass(field_type) and issubclass(field_type, Extension): - if not include_extensions: - continue - urn = field_type.__schema__ or "" - elif isclass(target_model) and issubclass(target_model, Extension): - urn = target_model().get_attribute_urn(field_name) - else: - urn = _scim_name(target_model, field_name) - - yield cls(urn) - - is_complex = ( - field_type is not None - and isclass(field_type) - and issubclass(field_type, ComplexAttribute) - ) - if include_subattributes and is_complex: - for sub_field_name in field_type.model_fields: # type: ignore[union-attr] - if not matches_filters(field_type, sub_field_name): # type: ignore[arg-type] - continue - sub_urn = f"{urn}.{_scim_name(field_type, sub_field_name)}" # type: ignore[arg-type] - yield cls(sub_urn) - - yield from iter_model_paths(model) # type: ignore[arg-type] - - if include_extensions and isclass(model) and issubclass(model, Resource): - for extension_model in model.get_extension_models().values(): - yield from iter_model_paths(extension_model) diff --git a/scim2_models/path/__init__.py b/scim2_models/path/__init__.py new file mode 100644 index 00000000..b7262eb4 --- /dev/null +++ b/scim2_models/path/__init__.py @@ -0,0 +1,61 @@ +"""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 +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 AttributeBinding +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", + "AttributeBinding", + "CompareOperator", + "Comparison", + "Evaluator", + "FilterNode", + "FilterVisitor", + "LogicalExpr", + "LogicalOperator", + "Not", + "Path", + "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/path/access.py b/scim2_models/path/access.py new file mode 100644 index 00000000..9f3c9b43 --- /dev/null +++ b/scim2_models/path/access.py @@ -0,0 +1,448 @@ +"""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 +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 ..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 .visitor import Evaluator + +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/binding.py b/scim2_models/path/binding.py new file mode 100644 index 00000000..42d3594e --- /dev/null +++ b/scim2_models/path/binding.py @@ -0,0 +1,99 @@ +"""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 +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/path/expressions.py b/scim2_models/path/expressions.py new file mode 100644 index 00000000..ee301a4d --- /dev/null +++ b/scim2_models/path/expressions.py @@ -0,0 +1,328 @@ +"""The syntax tree that filters and paths are parsed into, and rendered back from.""" + +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.path 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/path/filter.py b/scim2_models/path/filter.py new file mode 100644 index 00000000..8fcc21bd --- /dev/null +++ b/scim2_models/path/filter.py @@ -0,0 +1,366 @@ +"""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 +from typing import Generic +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 AttributeBinding +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 + +if TYPE_CHECKING: + from ..resources.resource import Resource + +ResourceT = TypeVar("ResourceT", bound="Resource[Any]") + + +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 + ) -> 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 + 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) + validate_value_filter(resolved, node.val_filter, strict=self.strict) + + +def validate_value_filter( + resolved: AttributeBinding, 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(_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, + 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. 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}") + + :meth:`quote` quotes a value dropped into a plain string the same way. + """ + + _ast: FilterNode + + @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__ + + 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_ + + @property + def ast(self) -> FilterNode: + """The parsed form of the filter. + + This is the tree a transpiler walks with a + :class:`~scim2_models.path.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 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 + ) -> 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 + 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.path 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 + ) -> 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 + ``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.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 + not bound to a model. + :raises InvalidFilterException: If ``strict`` and the attribute is unknown. + + >>> from scim2_models import Group, ScimFilter + >>> from scim2_models.path 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/path/grammar.py b/scim2_models/path/grammar.py new file mode 100644 index 00000000..54edf638 --- /dev/null +++ b/scim2_models/path/grammar.py @@ -0,0 +1,266 @@ +"""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 +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.path.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.path 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.path 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/path/path.py b/scim2_models/path/path.py new file mode 100644 index 00000000..2ac2f4e5 --- /dev/null +++ b/scim2_models/path/path.py @@ -0,0 +1,532 @@ +"""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 +from typing import TYPE_CHECKING +from typing import Any +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 .binding import _BoundToModels + +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 .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 .grammar import parse_path +from .resolution import AttributeBinding +from .resolution import designated_model +from .resolution import resolve_attr_path + +ResourceT = TypeVar("ResourceT", bound="Resource[Any]") + + +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 _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 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.""" + + 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] + + 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 that a path conforms to the ``PATH`` rule of :rfc:`RFC7644 §3.5.2 <7644#section-3.5.2>`. + + 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 + """ + if not path: + return + + node = parse_path(path) + + uri = _node_attr_path(node).uri + if uri is None: + return + + try: + URN(uri.lower()) + except ValueError as exc: + raise InvalidPathException( + path=path, detail=f"The path is not a valid URN: {exc}" + ) from exc + + @property + def ast(self) -> "PathNode | None": + """The parsed form of the path, or :data:`None` for the resource root. + + >>> 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. + + The attribute notation of :rfc:`RFC7644 §3.10 <7644#section-3.10>` is a + schema URN, an attribute and at most one of its sub-attributes. A value + selection or a comparison designates the values an attribute holds + rather than the attribute itself, so it has no place where a single + attribute is asked for. + + :raises InvalidPathException: If the path is not in attribute notation. + """ + if not isinstance(self.ast, AttrPath): + raise InvalidPathException( + path=str(self), + detail=f"{str(self)!r} is not in attribute notation", + ) + + @property + def schema(self) -> str | None: + """The schema URN portion of the path. + + For paths like "urn:...:User:userName", returns "urn:...:User". + For simple paths like "userName", returns 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, 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". + """ + designated = self._designated_attr_path() + if designated is None: + return "" + return str(replace(designated, uri=None)) + + @property + def parts(self) -> tuple[str, ...]: + """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 (). + """ + designated = self._designated_attr_path() + if designated is None: + return () + if designated.sub_attr: + return (designated.attr, designated.sub_attr) + return (designated.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 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. + + >>> 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 + + if self.ast is None: + return self.__scim_models__[0] + + for model in self.__scim_models__: + if (designated := designated_model(model, str(self))) is not None: + return designated + return None + + def resolve(self) -> "AttributeBinding | None": + """Bind this path to the attribute it designates on the bound model. + + :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.model is not None: + return None + + # A path that designates no attribute has already returned above, + # since the model property 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. + + ``Path[User]`` binds one, ``Path[User | Group]`` binds two, as an + endpoint covering several resource types does. A path is resolved + against the first of them declaring the attribute it names. + """ + return self.__scim_models__ + + 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 get(self, resource: ResourceT, *, strict: bool = True) -> Any: + """Get the value at this path from a resource. + + A path crossing a multi-valued attribute designates the sub-attribute of + each of its entries, so ``emails.value`` answers with one item per email, + in their order, :data:`None` included for an email carrying no value. + Writing through such a path reaches those same entries, which is what + makes the answer their mirror. An attribute holding no entry at all + answers :data:`None`, as any unassigned attribute does. + + :param resource: The resource to get the value from. + :param strict: If True, raise exceptions for invalid paths. + :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 get_value(self, resource) + except (InvalidPathException, InvalidFilterException): + if strict: + raise + return None + + def set( + self, + resource: ResourceT, + value: Any, + *, + is_add: bool = False, + strict: bool = True, + ) -> bool: + """Set a value at this path on a resource. + + A path crossing a multi-valued attribute writes the sub-attribute of each + of its entries, so ``emails.value`` gives every email the same value. An + unassigned multi-valued attribute has no entry to write to, and is left + alone. + + :param resource: The resource to set the value on. + :param value: The value to set. + :param is_add: If True and the target is multi-valued, append to the + list instead of replacing. Duplicates are not added. + :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 set_value(self, resource, value, is_add=is_add) + except (InvalidPathException, InvalidFilterException, NoTargetException): + if strict: + raise + return False + + def delete( + self, resource: ResourceT, value: Any | None = None, *, strict: bool = True + ) -> bool: + """Delete a value at this path from a resource. + + If value is None, the entire attribute is set to None. + If value is provided and the attribute is multi-valued, + only matching values are removed from the list. + A path crossing a multi-valued attribute removes the sub-attribute from + each of its entries, so ``emails.type`` leaves the emails in place and + untypes them. + + :param resource: The resource to delete the value from. + :param value: Optional specific value to remove from a list. + :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 delete_value(self, resource, value) + except (InvalidPathException, InvalidFilterException): + if strict: + raise + return False + + @classmethod + def iter_paths( + cls, + include_subattributes: bool = True, + include_extensions: bool = True, + required: "list[Required] | None" = None, + mutability: "list[Mutability] | None" = None, + uniqueness: "list[Uniqueness] | None" = None, + returned: "list[Returned] | None" = None, + case_exact: "list[CaseExact] | None" = None, + ) -> "Iterator[Path[ResourceT]]": + """Iterate over all paths for the bound model and its extensions. + + Requires the Path to be bound to a model type via ``Path[Model]``. + + :param include_subattributes: Whether to include sub-attribute paths. + :param include_extensions: Whether to include extension attributes. + :param required: Filter by Required annotation values (e.g., [Required.true]). + :param mutability: Filter by Mutability annotation values (e.g., [Mutability.read_write]). + :param uniqueness: Filter by Uniqueness annotation values (e.g., [Uniqueness.server]). + :param returned: Filter by Returned annotation values (e.g., [Returned.always]). + :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 + + if len(cls.__scim_models__) != 1: + raise TypeError( + "iter_paths requires a Path bound to one model: Path[Model]" + ) + model = cls.__scim_models__[0] + + selected = ( + (required, Required), + (mutability, Mutability), + (uniqueness, Uniqueness), + (returned, Returned), + (case_exact, CaseExact), + ) + + def matches_filters(target_model: type[BaseModel], field_name: str) -> bool: + return all( + target_model.get_field_annotation(field_name, annotation) in values + for values, annotation in selected + if values is not None + ) + + def iter_model_paths( + target_model: type[Resource[Any] | Extension], + ) -> "Iterator[Path[ResourceT]]": + for field_name in target_model.model_fields: + if field_name in ("meta", "id", "schemas"): + continue + + if not matches_filters(target_model, field_name): + continue + + field_type = target_model.get_field_root_type(field_name) + + urn: str + if isclass(field_type) and issubclass(field_type, Extension): + if not include_extensions: + continue + urn = field_type.__schema__ or "" + elif isclass(target_model) and issubclass(target_model, Extension): + urn = target_model().get_attribute_urn(field_name) + else: + urn = _scim_name(target_model, field_name) + + yield cls(urn) + + is_complex = ( + field_type is not None + and isclass(field_type) + and issubclass(field_type, ComplexAttribute) + ) + if include_subattributes and is_complex: + for sub_field_name in field_type.model_fields: # type: ignore[union-attr] + if not matches_filters(field_type, sub_field_name): # type: ignore[arg-type] + continue + sub_urn = f"{urn}.{_scim_name(field_type, sub_field_name)}" # type: ignore[arg-type] + yield cls(sub_urn) + + yield from iter_model_paths(model) # type: ignore[arg-type] + + if include_extensions and isclass(model) and issubclass(model, Resource): + for extension_model in model.get_extension_models().values(): + yield from iter_model_paths(extension_model) diff --git a/scim2_models/path/resolution.py b/scim2_models/path/resolution.py new file mode 100644 index 00000000..ad4a48de --- /dev/null +++ b/scim2_models/path/resolution.py @@ -0,0 +1,505 @@ +"""Binding a syntactic attribute path to the attribute a model declares.""" + +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 ..utils import _find_field_name +from .expressions import ORDERING_OPERATORS +from .expressions import STRING_OPERATORS +from .expressions import AttrPath +from .expressions import CompareOperator + +# 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 AttributeBinding: + """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 + + @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 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. + + 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: AttributeBinding) -> 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 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: + """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 +) -> AttributeBinding | 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:`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], AttributeBinding | 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 +) -> 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: + 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 AttributeBinding( + 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: AttributeBinding) -> 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 +) -> AttributeBinding | 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.path 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, +) -> AttributeBinding | 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: AttributeBinding, 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: 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 + 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: 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 + 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/scim2_models/path/visitor.py b/scim2_models/path/visitor.py new file mode 100644 index 00000000..ec8b80e8 --- /dev/null +++ b/scim2_models/path/visitor.py @@ -0,0 +1,347 @@ +"""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 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 AttributeBinding +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 + ) -> AttributeBinding | 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: AttributeBinding) -> 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: AttributeBinding + ) -> 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/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_doc_examples.py b/tests/test_doc_examples.py index 5d03125b..f4a0b25e 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 @@ -38,7 +51,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 +91,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 +121,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 +151,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={ @@ -210,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"]) @@ -328,7 +379,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 +435,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 +484,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 +559,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 +599,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 +637,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 +689,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( @@ -578,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/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index 47f00a2d..afee5238 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -13,6 +13,7 @@ from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context 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 @@ -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" + bound = Path[Model]("label").resolve().target_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..6a16a8a6 --- /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.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( + "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..4a1895af --- /dev/null +++ b/tests/test_filter_semantics.py @@ -0,0 +1,1178 @@ +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 AttributeBinding +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 Schema +from scim2_models import ScimFilter +from scim2_models import SearchRequest +from scim2_models import User +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}" +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_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) + + +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 = AttributeBinding( + 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 + assert resolved.target_is_multivalued is None + assert resolved.get_annotation(Required) 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" + assert SearchRequest(filter="active gt true").filter == "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_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..6716a297 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 @@ -72,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", @@ -157,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_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_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_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" - - -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" - ) - + assert path.resolve().urn == str(path) -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(): @@ -429,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(): @@ -454,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" @@ -840,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 @@ -848,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(): @@ -872,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 @@ -894,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(): @@ -1166,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(): @@ -1210,51 +1131,46 @@ 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 --- -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(): @@ -1420,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") @@ -1501,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(): @@ -1521,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 +# --- What the binding of a path reports --- -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 - - -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 --- @@ -1645,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(): @@ -1657,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(): @@ -1671,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(): @@ -1682,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(): @@ -1709,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(): @@ -1743,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(): @@ -1755,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(): @@ -1767,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(): @@ -1812,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(): @@ -1827,3 +1650,86 @@ 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, AttributeBinding) + 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, an attribute path none.""" + assert Path[User]("").resolve() is None + assert Path[User]("").model is User + + 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 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("").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.""" + 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").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").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( + "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_resolves_or_names_a_schema(model): + """Every path ``iter_paths`` yields designates an attribute or a schema. + + 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: + resolved = path.resolve() + if resolved is None: + assert path.model is not None + continue + + 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 new file mode 100644 index 00000000..72d28134 --- /dev/null +++ b/tests/test_path_value_selection.py @@ -0,0 +1,470 @@ +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 +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_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.value" + assert path.parts == ("emails", "value") + assert ( + path.resolve().urn == "urn:ietf:params:scim:schemas:core:2.0:User:emails.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) + + +@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. + + Reading the rendered expression instead of the attribute it designates used + to resolve nothing but a plain attribute path. + """ + 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 + + +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" + 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(): + """``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.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(): + 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(): + 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 a7f040a7..4e7891c5 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 @@ -129,9 +130,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 +146,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(): @@ -297,14 +299,83 @@ 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.resolve().target_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.resolve() 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 + 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(): @@ -330,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"}) @@ -341,3 +412,45 @@ 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.resolve().target_field_name == "user_name" + assert request.sort_by.resolve().target_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.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.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].resolve() is None diff --git a/tests/test_template_strings.py b/tests/test_template_strings.py new file mode 100644 index 00000000..a04606b3 --- /dev/null +++ b/tests/test_template_strings.py @@ -0,0 +1,96 @@ +"""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 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.path import Comparison +from scim2_models.path 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\""' + + +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"]' + ) diff --git a/uv.lock b/uv.lock index aa789ea9..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" @@ -736,6 +806,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 +1593,7 @@ name = "scim2-models" version = "0.7.0" source = { editable = "." } dependencies = [ + { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] @@ -1528,6 +1608,7 @@ dev = [ { name = "prek" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "sqlalchemy" }, { name = "starlette" }, { name = "tox-uv" }, ] @@ -1545,7 +1626,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 = [ @@ -1557,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" }, ] @@ -1796,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"