diff --git a/Containerfile b/Containerfile index 12b2e65..01811cd 100644 --- a/Containerfile +++ b/Containerfile @@ -6,9 +6,9 @@ ENV UV_COMPILE_BYTECODE=1 \ WORKDIR /app COPY pyproject.toml uv.lock README.md LICENSE ./ -RUN uv sync --locked --no-dev --extra precis --no-install-project +RUN uv sync --locked --no-dev --extra precis --extra cursor --no-install-project COPY scim2_server ./scim2_server -RUN uv sync --locked --no-dev --extra precis --no-editable +RUN uv sync --locked --no-dev --extra precis --extra cursor --no-editable FROM python:3.14-slim-trixie@sha256:51dafde81dbdb6ebde285137a295cf18a47ca95234fe388a343719cb97305b3d diff --git a/doc/_examples/asgi_integration.py b/doc/_examples/asgi_integration.py index a3a4874..da3f994 100644 --- a/doc/_examples/asgi_integration.py +++ b/doc/_examples/asgi_integration.py @@ -1,4 +1,5 @@ import json +import os from urllib.parse import parse_qsl from scim2_models import SCIMException @@ -10,7 +11,7 @@ from scim2_server.service import ScimService from scim2_server.utils import load_default_provider -service = ScimService(load_default_provider()) +service = ScimService(load_default_provider(), secret=os.environ["SCIM_SECRET"]) handler = AsyncScimHandler(service, AsyncInMemoryStorage()) diff --git a/doc/_examples/async_cursor_storage.py b/doc/_examples/async_cursor_storage.py new file mode 100644 index 0000000..b8df7e9 --- /dev/null +++ b/doc/_examples/async_cursor_storage.py @@ -0,0 +1,53 @@ +from doc._examples.async_sqlite_storage import AsyncSQLiteStorage +from scim2_server.storage import SearchPage + + +class AsyncCursorSQLiteStorage(AsyncSQLiteStorage): + supports_cursors = True + + async def search(self, resource_types, search_request, *, position=None): + types = {resource_type.name: resource_type for resource_type in resource_types} + [(total,)] = await self._select(types, "COUNT(*)") + if search_request.cursor is None: + return await self._search_index(types, total, search_request) + return await self._search_cursor(types, total, search_request, position) + + async def _search_index(self, types, total, search_request): + count = search_request.count + page = [-1 if count is None else count, search_request.start_index_0] + rows = await self._select(types, "*", "ORDER BY id LIMIT ? OFFSET ?", page) + return SearchPage(total, self._to_resources(types, rows)) + + async def _search_cursor(self, types, total, search_request, position): + count = search_request.count + limit = -1 if count is None else count + 1 + if position is None or "after" in position: + after = "" if position is None else position["after"] + clause = "AND id > ? ORDER BY id LIMIT ?" + rows = await self._select(types, "*", clause, [after, limit]) + resources = self._to_resources(types, rows[:count]) + more_after, more_before = len(rows) > len(resources), position is not None + else: + clause = "AND id < ? ORDER BY id DESC LIMIT ?" + rows = await self._select(types, "*", clause, [position["before"], limit]) + resources = self._to_resources(types, rows[:count][::-1]) + more_after, more_before = True, len(rows) > len(resources) + + result = SearchPage(total, resources) + if resources and more_after: + result.next = {"after": resources[-1].id} + if resources and more_before: + result.previous = {"before": resources[0].id} + return result + + async def _select(self, types, columns, clause="", parameters=()): + connection = await self.connect() + among = ", ".join("?" * len(types)) + cursor = await connection.execute( + f"SELECT {columns} FROM resources WHERE resource_type IN ({among}) {clause}", + [*types, *parameters], + ) + return list(await cursor.fetchall()) + + def _to_resources(self, types, rows): + return [self.row_to_resource(types[row["resource_type"]], row) for row in rows] diff --git a/doc/_examples/async_library_storage.py b/doc/_examples/async_library_storage.py index effbcf2..180b4d1 100644 --- a/doc/_examples/async_library_storage.py +++ b/doc/_examples/async_library_storage.py @@ -16,6 +16,7 @@ from doc._examples.library_storage import TABLES from scim2_server.storage import AsyncScimStorage +from scim2_server.storage import SearchPage class AsyncLibraryStorage(AsyncScimStorage): @@ -131,8 +132,12 @@ async def writing(self) -> AsyncIterator[aiosqlite.Connection]: raise UniquenessException from None async def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: connection = await self.connect() found: list[Resource[Any]] = [] for resource_type in resource_types: @@ -144,6 +149,5 @@ async def search( resource for resource in found if search_request.filter.match(resource) ] found = search_request.sort(found) - start = (search_request.start_index or 1) - 1 - stop = None if search_request.count is None else start + search_request.count - return len(found), found[start:stop] + page = found[search_request.start_index_0 : search_request.stop_index_0] + return SearchPage(len(found), page) diff --git a/doc/_examples/async_sqlite_storage.py b/doc/_examples/async_sqlite_storage.py index 38ebeff..8293af0 100644 --- a/doc/_examples/async_sqlite_storage.py +++ b/doc/_examples/async_sqlite_storage.py @@ -19,6 +19,7 @@ from doc._examples.sqlite_storage import TABLE from scim2_server.storage import AsyncScimStorage +from scim2_server.storage import SearchPage class AsyncSQLiteStorage(AsyncScimStorage): @@ -153,8 +154,12 @@ async def writing(self) -> AsyncIterator[aiosqlite.Connection]: raise UniquenessException from None async def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: connection = await self.connect() found = [] for resource_type in resource_types: @@ -171,6 +176,5 @@ async def search( resource for resource in found if search_request.filter.match(resource) ] found = search_request.sort(found) - start = (search_request.start_index or 1) - 1 - stop = None if search_request.count is None else start + search_request.count - return len(found), found[start:stop] + page = found[search_request.start_index_0 : search_request.stop_index_0] + return SearchPage(len(found), page) diff --git a/doc/_examples/cursor_storage.py b/doc/_examples/cursor_storage.py new file mode 100644 index 0000000..0ea9057 --- /dev/null +++ b/doc/_examples/cursor_storage.py @@ -0,0 +1,51 @@ +from doc._examples.sqlite_storage import SQLiteStorage +from scim2_server.storage import SearchPage + + +class CursorSQLiteStorage(SQLiteStorage): + supports_cursors = True + + def search(self, resource_types, search_request, *, position=None): + types = {resource_type.name: resource_type for resource_type in resource_types} + [(total,)] = self._select(types, "COUNT(*)") + if search_request.cursor is None: + return self._search_index(types, total, search_request) + return self._search_cursor(types, total, search_request, position) + + def _search_index(self, types, total, search_request): + count = search_request.count + page = [-1 if count is None else count, search_request.start_index_0] + rows = self._select(types, "*", "ORDER BY id LIMIT ? OFFSET ?", page) + return SearchPage(total, self._to_resources(types, rows)) + + def _search_cursor(self, types, total, search_request, position): + count = search_request.count + limit = -1 if count is None else count + 1 + if position is None or "after" in position: + after = "" if position is None else position["after"] + clause = "AND id > ? ORDER BY id LIMIT ?" + rows = self._select(types, "*", clause, [after, limit]) + resources = self._to_resources(types, rows[:count]) + more_after, more_before = len(rows) > len(resources), position is not None + else: + clause = "AND id < ? ORDER BY id DESC LIMIT ?" + rows = self._select(types, "*", clause, [position["before"], limit]) + resources = self._to_resources(types, rows[:count][::-1]) + more_after, more_before = True, len(rows) > len(resources) + + result = SearchPage(total, resources) + if resources and more_after: + result.next = {"after": resources[-1].id} + if resources and more_before: + result.previous = {"before": resources[0].id} + return result + + def _select(self, types, columns, clause="", parameters=()): + among = ", ".join("?" * len(types)) + return self.connection.execute( + f"SELECT {columns} FROM resources WHERE resource_type IN ({among}) {clause}", + [*types, *parameters], + ).fetchall() + + def _to_resources(self, types, rows): + return [self.row_to_resource(types[row["resource_type"]], row) for row in rows] diff --git a/doc/_examples/library_storage.py b/doc/_examples/library_storage.py index d3c6ced..79cb9b5 100644 --- a/doc/_examples/library_storage.py +++ b/doc/_examples/library_storage.py @@ -27,6 +27,7 @@ from scim2_models import User from scim2_server.storage import ScimStorage +from scim2_server.storage import SearchPage MEMBERS = """ CREATE TABLE IF NOT EXISTS members ( @@ -233,8 +234,12 @@ def writing(self) -> Iterator[None]: raise UniquenessException from None def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: found: list[Resource[Any]] = [] for resource_type in resource_types: table = TABLES[resource_type.name] @@ -245,6 +250,5 @@ def search( resource for resource in found if search_request.filter.match(resource) ] found = search_request.sort(found) - start = (search_request.start_index or 1) - 1 - stop = None if search_request.count is None else start + search_request.count - return len(found), found[start:stop] + page = found[search_request.start_index_0 : search_request.stop_index_0] + return SearchPage(len(found), page) diff --git a/doc/_examples/sqlite_storage.py b/doc/_examples/sqlite_storage.py index b012dfb..232fb98 100644 --- a/doc/_examples/sqlite_storage.py +++ b/doc/_examples/sqlite_storage.py @@ -17,6 +17,7 @@ from scim2_models import UniquenessException from scim2_server.storage import ScimStorage +from scim2_server.storage import SearchPage TABLE = """ CREATE TABLE IF NOT EXISTS resources ( @@ -148,8 +149,12 @@ def writing(self) -> Iterator[None]: raise UniquenessException from None def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: found = [] for resource_type in resource_types: rows = self.connection.execute( @@ -162,6 +167,5 @@ def search( resource for resource in found if search_request.filter.match(resource) ] found = search_request.sort(found) - start = (search_request.start_index or 1) - 1 - stop = None if search_request.count is None else start + search_request.count - return len(found), found[start:stop] + page = found[search_request.start_index_0 : search_request.stop_index_0] + return SearchPage(len(found), page) diff --git a/doc/_examples/wsgi_integration.py b/doc/_examples/wsgi_integration.py index e9420ab..0b54d04 100644 --- a/doc/_examples/wsgi_integration.py +++ b/doc/_examples/wsgi_integration.py @@ -1,4 +1,5 @@ import json +import os from urllib.parse import parse_qsl from wsgiref.util import application_uri @@ -11,7 +12,7 @@ from scim2_server.service import ScimService from scim2_server.utils import load_default_provider -service = ScimService(load_default_provider()) +service = ScimService(load_default_provider(), secret=os.environ["SCIM_SECRET"]) handler = ScimHandler(service, InMemoryStorage()) diff --git a/doc/changelog.rst b/doc/changelog.rst index de0cc93..9398110 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,58 @@ Changelog ========= +[0.8.0] - Unreleased +-------------------- + +Added +^^^^^ +- Cursor pagination of :rfc:`9865`. A configuration that announces it in ``pagination`` gets + ``nextCursor`` and ``previousCursor`` in the search responses. The clients cannot read or + forge the cursors. The server refuses a cursor of another query, another ``count``, or older + than ``cursorTimeout``. The cursors give stable pages: a resource that exists during the whole + paging is returned once, even when others are created or deleted. Cursor pagination needs the + new ``secret`` parameter of :class:`~scim2_server.service.ScimService`, the ``cursor`` extra, and + a storage that sets the new + :attr:`~scim2_server.storage.ScimStorage.supports_cursors`, such as + :class:`~scim2_server.memory.InMemoryStorage`. The server refuses to start otherwise. +- The ``scim2-server`` command pages with cursors when its ``--service-provider-config`` + announces them. Its container image includes the ``cursor`` extra. +- :class:`~scim2_server.applications.wsgi.WSGIApplication` and + :class:`~scim2_server.applications.asgi.ASGIApplication` give their default service the secret + of the ``SCIM2_SERVER_SECRET`` environment variable. +- The ``maxPageSize`` of ``pagination`` bounds the ``count`` of every search. +- :class:`~scim2_server.testing.ScimStorageContract` and + :class:`~scim2_server.testing.AsyncScimStorageContract` check that the cursors of the + storages that support them give stable pages. + +Changed +^^^^^^^ +- :meth:`ScimStorage.search ` returns a + :class:`~scim2_server.storage.SearchPage` instead of a tuple, and takes the ``position`` of the + page when a cursor pages the resources. This also applies to code that calls it directly, such + as the ``search`` method of :class:`~scim2_server.memory.InMemoryStorage`. +- :class:`~scim2_server.memory.InMemoryStorage` returns the resources in the order of their + identifiers when the search has no ``sortBy``, instead of the order of creation. The same goes + for the resources with the same sort value. +- For the subclasses of :class:`~scim2_server.service.ScimService`: + :meth:`~scim2_server.service.ScimService.read_search`, ``read_search_query`` and + ``read_search_body`` return the search request with the position of the page its cursor asks + for, and :meth:`~scim2_server.service.ScimService.search_response` takes the searched resource + types and a :class:`~scim2_server.storage.SearchPage` instead of the number of resources and + the page. +- The WSGI and ASGI applications keep the query parameters without a value, as scim2-flask does. + An empty ``filter``, ``sortBy`` or ``count`` gets a 400 response instead of being ignored. +- With ``pagination`` in the configuration, a search without ``count`` gets ``defaultPageSize`` + resources, by index as by cursor, instead of every resource up to ``maxResults``. +- A search with a ``cursor`` gets a 501 response when the configuration does not announce + cursor pagination, instead of ignoring the cursor. + +Deprecated +^^^^^^^^^^ +- A storage whose ``search`` method does not accept ``position`` raises a + :class:`DeprecationWarning`. Such a storage pages by index only. scim2-server 0.9 will require + ``position``. + [0.7.1] - 2026-10-07 -------------------- diff --git a/doc/conf.py b/doc/conf.py index 82fe37c..925ee15 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -18,6 +18,7 @@ "sphinxarg.ext", "sphinx_design", "sphinx_issues", + "sphinxcontrib.mermaid", ] templates_path = ["_templates"] @@ -49,6 +50,17 @@ nitpicky = True +# Shibuya inverts the diagrams in dark mode. +mermaid_light_theme = "neutral" +mermaid_dark_theme = "neutral" +mermaid_init_config = { + "startOnLoad": False, + "block": {"useMaxWidth": False}, +} +mermaid_fullscreen = False +mermaid_height = "auto" +mermaid_width = "70%" + # Autodoc renders an annotation with the module the object is defined in. # Sibling documentations only publish the public names, so unresolved # references are retried with them. diff --git a/doc/conftest.py b/doc/conftest.py new file mode 100644 index 0000000..b9626ff --- /dev/null +++ b/doc/conftest.py @@ -0,0 +1,5 @@ +import os + +# The examples read the secret of the service from the environment, as a +# deployment does. Some of them read it when pytest imports them. +os.environ.setdefault("SCIM_SECRET", "a secret every worker shares") diff --git a/doc/explanation/index.rst b/doc/explanation/index.rst index 35e6cb1..d9204fa 100644 --- a/doc/explanation/index.rst +++ b/doc/explanation/index.rst @@ -2,8 +2,9 @@ Explanation =========== These pages give the reasons behind the design of scim2-server: how the work is split between -its layers, how it writes resources safely, and how it runs bulk requests. They are written to -be read away from the keyboard, rather than consulted while completing a task. +its layers, how it writes resources safely, how it runs bulk requests, and how it pages the +results. They are written to be read away from the keyboard, rather than consulted while +completing a task. .. toctree:: :maxdepth: 1 @@ -11,3 +12,4 @@ be read away from the keyboard, rather than consulted while completing a task. architecture storage bulk + pagination diff --git a/doc/explanation/pagination.rst b/doc/explanation/pagination.rst new file mode 100644 index 0000000..528f74d --- /dev/null +++ b/doc/explanation/pagination.rst @@ -0,0 +1,150 @@ +Paging the results +================== + +This page explains what a client gets when it reads a large collection page by page, and why +scim2-server pages with ``startIndex`` and with cursors the way it does. The steps to announce +the cursors are in :doc:`../how-to/page-with-cursors`, and the rules a storage follows are in +:doc:`../how-to/write-a-storage`. + +What changes between two pages +------------------------------ + +Each page is a new request. Between two pages, other clients can create, update or delete +resources. Take four users sorted by name, ``A B C D``, read two by two. The client reads the +first page, ``A B``, and then someone changes the collection before the client reads the +second page. + +How the server finds the second page decides what the client sees. There are three levels of +guarantee. + +Offsets + The second page is "the resources from the third one", counted again on the current + collection. If ``A`` is deleted, the collection becomes ``B C D``, and the second page is + ``D``: the client never sees ``C``. If ``A0`` is created, the collection becomes + ``A0 A B C D``, and the second page is ``B C``: the client sees ``B`` twice. A change moves + resources that have nothing to do with it. + + .. mermaid:: + :align: center + + block-beta + columns 6 + h0[" "] h1["1"] h2["2"] h3["3"] h4["4"] h5["5"] + r1["read page 1"] a1["A"] b1["B"] c1["C"] d1["D"] space + r2["delete A"] b2["B"] c2["C"] d2["D"] space space + r3["read page 2"] b3["B"] c3["C"] d3["D"] space space + classDef label fill:none,stroke:none + classDef page fill:#4f7fd1,color:#fff,stroke:#2b5aa8 + classDef wrong fill:none,stroke:#d14f4f,stroke-width:3px,color:#d14f4f + classDef pagewrong fill:#4f7fd1,color:#fff,stroke:#d14f4f,stroke-width:3px + class h0,h1,h2,h3,h4,h5,r1,r2,r3 label + class a1,b1,d3 page + class c3 wrong + + .. mermaid:: + :align: center + + block-beta + columns 6 + h0[" "] h1["1"] h2["2"] h3["3"] h4["4"] h5["5"] + r1["read page 1"] a1["A"] b1["B"] c1["C"] d1["D"] space + r2["create A0"] n2["A0"] a2["A"] b2["B"] c2["C"] d2["D"] + r3["read page 2"] n3["A0"] a3["A"] b3["B"] c3["C"] d3["D"] + classDef label fill:none,stroke:none + classDef page fill:#4f7fd1,color:#fff,stroke:#2b5aa8 + classDef wrong fill:none,stroke:#d14f4f,stroke-width:3px,color:#d14f4f + classDef pagewrong fill:#4f7fd1,color:#fff,stroke:#d14f4f,stroke-width:3px + class h0,h1,h2,h3,h4,h5,r1,r2,r3 label + class a1,b1,c3 page + class b3 pagewrong + +Stable pages + The second page is "the resources after ``B``" in the sort order. If ``A`` is deleted, or if + ``A0`` is created, the second page is still ``C D``. A resource that exists during the whole + paging, and whose sort value does not change, is seen exactly once. A resource created or + deleted meanwhile is seen or not, depending on where it sorts. A resource whose sort value + changes can be seen twice, or not at all. Each page shows the resources as they are when the + page is read. + + .. mermaid:: + :align: center + + block-beta + columns 6 + r1["read page 1"] space a1["A"] b1["B"] c1["C"] d1["D"] + r2["delete A"] space space b2["B"] c2["C"] d2["D"] + r3["read page 2"] space space b3["B"] c3["C"] d3["D"] + classDef label fill:none,stroke:none + classDef page fill:#4f7fd1,color:#fff,stroke:#2b5aa8 + class r1,r2,r3 label + class a1,b1,c3,d3 page + + .. mermaid:: + :align: center + + block-beta + columns 6 + r1["read page 1"] space a1["A"] b1["B"] c1["C"] d1["D"] + r2["create A0"] n2["A0"] a2["A"] b2["B"] c2["C"] d2["D"] + r3["read page 2"] n3["A0"] a3["A"] b3["B"] c3["C"] d3["D"] + classDef label fill:none,stroke:none + classDef page fill:#4f7fd1,color:#fff,stroke:#2b5aa8 + class r1,r2,r3 label + class a1,b1,c3,d3 page + +Snapshot + Every page shows the collection as it was when the first page was read. Every resource of + that moment is seen exactly once, in its state of that moment. If ``B0`` is created, the + second page of stable pages is ``B0 C``, and the second page of a snapshot is still ``C D``. + It takes an open transaction or a copy of the results for each paging client, or a database + that reads as of a past date. + + .. mermaid:: + :align: center + + block-beta + columns 6 + r1["read page 1"] a1["A"] b1["B"] space c1["C"] d1["D"] + r2["create B0"] a2["A"] b2["B"] n2["B0"] c2["C"] d2["D"] + r3["read page 2"] a3["A"] b3["B"] space c3["C"] d3["D"] + classDef label fill:none,stroke:none + classDef page fill:#4f7fd1,color:#fff,stroke:#2b5aa8 + class r1,r2,r3 label + class a1,b1,c3,d3 page + +What the RFCs say +----------------- + +:rfc:`RFC 7644 §3.4.2.4 <7644#section-3.4.2.4>` defines ``startIndex``, which gives offsets. It +warns the clients: "Because pagination is not stateful, clients MUST be prepared to handle +inconsistent results." + +:rfc:`RFC 9865 <9865>` adds the cursors. It requires no more than offsets: it does not require +stable pages, nor a snapshot. A server can announce the cursors, and still return gaps and +duplicates, as with ``startIndex``. The goal of the RFC is to let a server reuse the cursors of +its database or of its API, instead of translating them into ``startIndex`` +(:rfc:`RFC 9865 §1 <9865#section-1>`). + +Neither RFC defines an order when the client gives no ``sortBy``. Two searches without +``sortBy`` can return the resources in different orders. + +What scim2-server does +---------------------- + +``startIndex`` gives offsets, as :rfc:`RFC 7644 <7644#section-3.4.2.4>` defines it. + +The cursors give stable pages. A cursor holds the sort value and the identifier of the last +resource of the page, and the next page starts after them. A storage that cannot give stable +pages only pages by index. The server checks this when it starts: it refuses a storage without +cursors when its configuration announces them. + +With cursors, stable pages are the least a storage gives. A storage can give more: a database +that reads as of a past date can keep that date in the position, and give a snapshot. The +server itself keeps no state between two pages. A cursor is an encrypted value that the client +sends back. + +Without ``sortBy``, the storage chooses the order. Stable pages need a total order: the storage +orders the resources with the same sort value by a unique value, such as the identifier. + +Each page checks the rights of the client again. A cursor gives no right of its own +(:rfc:`RFC 9865 §5.2 <9865#section-5.2>`). diff --git a/doc/how-to/deploy-the-server.rst b/doc/how-to/deploy-the-server.rst index d9fa06f..bb66952 100644 --- a/doc/how-to/deploy-the-server.rst +++ b/doc/how-to/deploy-the-server.rst @@ -126,6 +126,7 @@ root URL of the SCIM endpoints, the resource type, and the identifier of the res .. doctest:: + >>> import os >>> from scim2_server.service import ScimService >>> class PublicService(ScimService): @@ -147,9 +148,8 @@ Pass the service to the application, built upon the same provider: >>> from scim2_server.utils import load_default_provider >>> provider = load_default_provider() - >>> app = WSGIApplication( - ... InMemoryStorage(), provider, service=PublicService(provider) - ... ) + >>> service = PublicService(provider, secret=os.environ["SCIM_SECRET"]) + >>> app = WSGIApplication(InMemoryStorage(), provider, service=service) .. tab-item:: Async :sync: async @@ -159,22 +159,19 @@ Pass the service to the application, built upon the same provider: >>> from scim2_server.applications.asgi import ASGIApplication >>> from scim2_server.memory import AsyncInMemoryStorage - >>> async_app = ASGIApplication( - ... AsyncInMemoryStorage(), provider, service=PublicService(provider) - ... ) + >>> async_app = ASGIApplication(AsyncInMemoryStorage(), provider, service=service) A created resource then carries the URL of the service: .. doctest:: - >>> from werkzeug.test import Client + >>> from scim2_client.engines.wsgi import WSGISCIMClient + >>> from scim2_models import User - >>> response = Client(app).post( - ... "/v2/Users", json={"userName": "bjensen"}, content_type="application/scim+json" - ... ) - >>> response.headers["Location"] == f"https://scim.example/Users/{response.json['id']}" - True - >>> response.json["meta"]["location"] == response.headers["Location"] + >>> scim_client = WSGISCIMClient(app, base_url="http://localhost/v2") + >>> scim_client.discover() + >>> user = scim_client.create(User(user_name="bjensen")) + >>> user.meta.location == f"https://scim.example/Users/{user.id}" True The routes of the server do not change: they stay those of diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst index 7e9cabb..a0b71e1 100644 --- a/doc/how-to/index.rst +++ b/doc/how-to/index.rst @@ -13,5 +13,6 @@ ends where an application resumes its own work, and names what it assumes. integrate-a-web-framework authenticate-the-clients deploy-the-server + page-with-cursors serve-the-me-endpoint serve-several-tenants diff --git a/doc/how-to/integrate-a-web-framework.rst b/doc/how-to/integrate-a-web-framework.rst index 999037b..f9be29a 100644 --- a/doc/how-to/integrate-a-web-framework.rst +++ b/doc/how-to/integrate-a-web-framework.rst @@ -26,12 +26,15 @@ over an :class:`~scim2_server.storage.AsyncScimStorage`: .. doctest:: + >>> import os >>> from scim2_server.handler import ScimHandler >>> from scim2_server.memory import InMemoryStorage >>> from scim2_server.service import ScimService >>> from scim2_server.utils import load_default_provider - >>> service = ScimService(load_default_provider()) + >>> service = ScimService( + ... load_default_provider(), secret=os.environ["SCIM_SECRET"] + ... ) >>> handler = ScimHandler(service, InMemoryStorage()) .. tab-item:: Async @@ -39,19 +42,23 @@ over an :class:`~scim2_server.storage.AsyncScimStorage`: .. doctest:: + >>> import os >>> from scim2_server.handler import AsyncScimHandler >>> from scim2_server.memory import AsyncInMemoryStorage >>> from scim2_server.service import ScimService >>> from scim2_server.utils import load_default_provider - >>> service = ScimService(load_default_provider()) + >>> service = ScimService( + ... load_default_provider(), secret=os.environ["SCIM_SECRET"] + ... ) >>> handler = AsyncScimHandler(service, AsyncInMemoryStorage()) The handler needs two objects: - the :class:`~scim2_server.service.ScimService` applies the SCIM rules to the resources of a :class:`~scim2_models.ScimProvider`. This provider describes the users and the groups of - :rfc:`RFC 7643 <7643>`. Keep a reference to the service, because the error handler of + :rfc:`RFC 7643 <7643>`. The secret protects the cursors of :doc:`page-with-cursors`. Read it from the + configuration of the deployment. Keep a reference to the service, because the error handler of `Return the response`_ uses it. - the storage reads and writes the resources. Replace the in-memory storage with the storage of the application. diff --git a/doc/how-to/page-with-cursors.rst b/doc/how-to/page-with-cursors.rst new file mode 100644 index 0000000..8ee839b --- /dev/null +++ b/doc/how-to/page-with-cursors.rst @@ -0,0 +1,170 @@ +Page the results with cursors +============================= + +Use this guide to let the clients page the search results with the cursors of :rfc:`9865`, +instead of ``startIndex`` (:rfc:`RFC 7644 §3.4.2.4 <7644#section-3.4.2.4>`). It is for +developers whose clients read large collections, such as a directory that a client copies page by +page. It assumes a server built as :doc:`deploy-the-server` does, with +:class:`~scim2_server.memory.InMemoryStorage`. The last section adds the cursors to a storage +written as :doc:`write-a-storage` explains. + +A client asks for the first page with an empty ``cursor``. Each response gives a +``nextCursor``, which the client sends back to get the next page, until the last page, which has +none. A response may also give a ``previousCursor`` (:rfc:`RFC 9865 §2 <9865#section-2>`). + +Install the cursor extra +------------------------ + +The server encrypts the cursors with the ``cryptography`` library. Install the ``cursor`` +extra: + +.. code-block:: console + + $ pip install "scim2-server[cursor]" + +Announce the cursors +-------------------- + +Announce cursor pagination with a :class:`~scim2_models.Pagination` in the ``pagination`` +attribute of the :class:`~scim2_models.ServiceProviderConfig` of the provider: + +.. doctest:: + + >>> from scim2_models import Pagination + >>> from scim2_server.utils import load_default_provider + + >>> provider = load_default_provider() + >>> provider.config.pagination = Pagination( + ... cursor=True, + ... index=True, + ... default_page_size=100, + ... max_page_size=1000, + ... cursor_timeout=3600, + ... ) + +The attributes of ``pagination`` decide how the server pages: + +- ``cursor`` and ``index`` announce the methods the server accepts. Without ``pagination``, the + server accepts ``startIndex`` only. +- ``default_pagination_method`` is the method of a search without ``cursor`` or + ``startIndex``. Without it, the server pages by index when ``index`` is ``True``, as + :rfc:`RFC 9865 §2.4 <9865#section-2.4>` recommends. +- ``default_page_size`` is the ``count`` of a search without ``count``. +- ``max_page_size`` bounds the ``count`` of every search, as ``maxResults`` of ``filter`` does. +- ``cursor_timeout`` is the number of seconds a cursor stays valid. Without it, the cursors do + not expire. + +Give the service a secret +------------------------- + +The server encrypts the cursors with keys that it derives from a secret, so that the clients +can neither read nor forge them. Generate a secret once: + +.. code-block:: console + + $ python -c "import secrets; print(secrets.token_urlsafe(32))" + +Keep it in the configuration of the deployment, such as an environment variable. Pass it to the +:class:`~scim2_server.service.ScimService`, and the service to the application: + +.. doctest:: + + >>> import os + >>> from scim2_server.applications.wsgi import WSGIApplication + >>> from scim2_server.memory import InMemoryStorage + >>> from scim2_server.service import ScimService + + >>> service = ScimService(provider, secret=os.environ["SCIM_SECRET"]) + >>> application = WSGIApplication(InMemoryStorage(), provider, service) + +Every process of the server needs the same secret: a cursor that one worker issues comes back to +another one. A new secret makes the cursors of the old one invalid, so the clients that are +paging start again from the first page. + +Without a secret, or without the ``cursor`` extra, the service refuses to start when the +configuration announces cursor pagination. + +Check the pages +--------------- + +The client sends ``cursor`` and ``count``, with the same filter and the same sort on every +page. With the WSGI engine of :doc:`scim2-client `, which calls the +application directly: + +.. doctest:: + + >>> from scim2_client.engines.wsgi import WSGISCIMClient + >>> from scim2_models import SearchRequest + >>> from scim2_models import User + + >>> scim_client = WSGISCIMClient(application, base_url="http://localhost/v2") + >>> scim_client.discover() + >>> for user_name in ("alice", "bob", "carol"): + ... _ = scim_client.create(User(user_name=user_name)) + + >>> first = scim_client.query( + ... User, query_parameters=SearchRequest(cursor="", count=2, sort_by="userName") + ... ) + >>> [user.user_name for user in first.resources] + ['alice', 'bob'] + >>> second = scim_client.query( + ... User, + ... query_parameters=SearchRequest(cursor=first.next_cursor, count=2, sort_by="userName"), + ... ) + >>> [user.user_name for user in second.resources], second.next_cursor + (['carol'], None) + +A cursor only serves the query it comes from: the server refuses it for another filter, sort or +count, or past ``cursor_timeout``. :meth:`~scim2_server.service.ScimService.read_search` lists +these errors. + +The cursors give stable pages: a resource that exists during the whole paging is returned once, +even when other resources are created or deleted between two pages. +:doc:`../explanation/pagination` explains what the cursors guarantee. + +Support the cursors in a storage +-------------------------------- + +The cursors need a storage that gives stable pages: :doc:`../explanation/pagination` explains +what they guarantee. :class:`~scim2_server.memory.InMemoryStorage` gives them. To add the +cursors to another storage, set :attr:`~scim2_server.storage.ScimStorage.supports_cursors`, and +page with a cursor when :attr:`~scim2_models.SearchRequest.cursor` is not ``None``. The +``position`` argument then tells which page to return. It is ``None`` on the first page. + +The storage below extends the one of :doc:`write-a-storage`, and pages its resources by +identifier. Its server announces neither the filter nor the sort: + +.. tab-set:: + :class: outline + + .. tab-item:: Sync + :sync: sync + + .. literalinclude:: ../_examples/cursor_storage.py + :language: python + :pyobject: CursorSQLiteStorage + + .. tab-item:: Async + :sync: async + + .. literalinclude:: ../_examples/async_cursor_storage.py + :language: python + :pyobject: AsyncCursorSQLiteStorage + +- The position of the next page is ``{"after": ...}``, with the identifier of the last resource + of the page. The position of the previous page is ``{"before": ...}``, with the identifier of + the first one. A position is a value that JSON can hold. +- The next page selects the rows after this identifier. The previous page selects the rows + before it, in the reverse order, and reverses them afterwards. +- The storage reads ``count + 1`` rows to know whether another page follows. + +A storage that supports the sort orders the rows by the sort value, then by identifier, so that +no two rows are equal. Its positions also hold the sort value of the resource, as +:meth:`~scim2_models.SearchRequest.sort_value` gives it. A row without sort value comes last +when ascending, and first when descending, so the storage writes the condition on the sort value +by hand, rather than with a row comparison, which does not handle ``NULL``. + +A backend that can only page with offsets does not set +:attr:`~scim2_server.storage.ScimStorage.supports_cursors`, and its server pages by index only. +The server refuses to start when its configuration announces the cursors and the storage does +not support them. diff --git a/doc/how-to/write-a-storage.rst b/doc/how-to/write-a-storage.rst index a5e69c5..710ea7d 100644 --- a/doc/how-to/write-a-storage.rst +++ b/doc/how-to/write-a-storage.rst @@ -233,8 +233,8 @@ Search the resources :meth:`~scim2_server.storage.ScimStorage.search` receives the resource types to search, and a validated :class:`~scim2_models.SearchRequest`. A search on an endpoint covers one resource type, -and a search at the root covers all of them. Filter, sort and page the resources, and return the -number of matching resources with the page: +and a search at the root covers all of them. Filter, sort and page the resources, and return a +:class:`~scim2_server.storage.SearchPage` with the page and the number of matching resources: .. tab-set:: :class: outline @@ -258,6 +258,14 @@ number of matching resources with the page: Count the matching resources before paging them: ``totalResults`` counts every matching resource, and the page holds at most ``count`` of them. +A search receives a ``position`` too. It locates a page when a cursor pages the resources +(:rfc:`9865`). The storage gives the positions of the next and the previous pages in the +:class:`~scim2_server.storage.SearchPage`, and the server passes them back for the next request. +The cursors must give stable pages, even when resources are created or deleted between two pages: +:doc:`../explanation/pagination` explains why. A storage that gives them sets +:attr:`~scim2_server.storage.ScimStorage.supports_cursors`, and :doc:`page-with-cursors` +explains how. The storage of the example pages by index only. + Search in a database -------------------- @@ -269,6 +277,8 @@ The search of the example reads every resource. A database storage translates th - :attr:`~scim2_models.SearchRequest.sort_by`, :attr:`~scim2_models.SearchRequest.sort_order`, :attr:`~scim2_models.SearchRequest.start_index` and :attr:`~scim2_models.SearchRequest.count` give the order and the page; +- when :attr:`~scim2_models.SearchRequest.cursor` is not ``None``, a cursor pages the resources + from ``position``, as :doc:`page-with-cursors` explains; - a filter the storage cannot translate raises :class:`~scim2_models.InvalidFilterException`. The server then answers 400. Do not filter in Python after the query has paged the results: ``totalResults`` would be wrong. diff --git a/doc/index.rst b/doc/index.rst index 998ea00..447eba7 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -20,13 +20,15 @@ scim2-server is for projects that need to: .. doctest:: + >>> import os >>> from scim2_server.handler import ScimHandler >>> from scim2_server.memory import InMemoryStorage >>> from scim2_server.requests import ScimRequest >>> from scim2_server.service import ScimService >>> from scim2_server.utils import load_default_provider - >>> handler = ScimHandler(ScimService(load_default_provider()), InMemoryStorage()) + >>> service = ScimService(load_default_provider(), secret=os.environ["SCIM_SECRET"]) + >>> handler = ScimHandler(service, InMemoryStorage()) >>> response = handler.handle( ... ScimRequest( ... "POST", diff --git a/doc/overview.rst b/doc/overview.rst index fa343a6..45f37b8 100644 --- a/doc/overview.rst +++ b/doc/overview.rst @@ -27,7 +27,8 @@ Describe the service A :class:`~scim2_models.ScimProvider` describes the service: the schemas it knows, the resources it serves, and the features it supports. :func:`~scim2_server.utils.load_default_provider` -serves the users and the groups of :rfc:`RFC 7643 <7643>`, with every feature: +serves the users and the groups of :rfc:`RFC 7643 <7643>`, with every feature but the cursors +of :doc:`how-to/page-with-cursors`: .. doctest:: @@ -70,17 +71,21 @@ Serve a request A :class:`~scim2_server.handler.ScimHandler` serves the SCIM requests with a :class:`~scim2_server.service.ScimService` and a storage. :class:`~scim2_server.memory.InMemoryStorage` keeps the resources in memory. A -:class:`~scim2_server.requests.ScimRequest` holds a request, independent of any web framework: +:class:`~scim2_server.requests.ScimRequest` holds a request, independent of any web framework. +The service takes a secret, which protects the cursors of :doc:`how-to/page-with-cursors`. Read +it from the configuration of the deployment, such as the ``SCIM_SECRET`` environment variable: .. doctest:: >>> import json + >>> import os >>> from scim2_server.handler import ScimHandler >>> from scim2_server.memory import InMemoryStorage >>> from scim2_server.requests import ScimRequest >>> from scim2_server.service import ScimService - >>> handler = ScimHandler(ScimService(provider), InMemoryStorage()) + >>> service = ScimService(provider, secret=os.environ["SCIM_SECRET"]) + >>> handler = ScimHandler(service, InMemoryStorage()) >>> member = { ... "schemas": [ ... "urn:ietf:params:scim:schemas:core:2.0:User", diff --git a/doc/reference/cli.rst b/doc/reference/cli.rst index db94425..79fc6da 100644 --- a/doc/reference/cli.rst +++ b/doc/reference/cli.rst @@ -39,7 +39,10 @@ discovery endpoints: - ``--schema``: a list of :class:`~scim2_models.Schema` objects; - ``--resource-type``: a list of :class:`~scim2_models.ResourceType` objects; - ``--service-provider-config``: a :class:`~scim2_models.ServiceProviderConfig` object, to turn - a feature such as the bulk requests or the sorting on or off. + a feature such as the bulk requests or the sorting on or off. The cursor pagination of + :doc:`../how-to/page-with-cursors` is off by default, and needs the ``cursor`` extra. The + server encrypts its cursors with the secret of the ``SCIM2_SERVER_SECRET`` environment + variable, or else with a secret drawn at start, so that its cursors do not survive a restart. .. code-block:: console diff --git a/doc/reference/service.rst b/doc/reference/service.rst index 430eeb2..461ae47 100644 --- a/doc/reference/service.rst +++ b/doc/reference/service.rst @@ -7,3 +7,6 @@ The SCIM protocol, without input or output. :members: .. autofunction:: scim2_server.service.is_json_media_type + +.. autoclass:: scim2_server.cursor.Cursors + :members: diff --git a/doc/reference/storages.rst b/doc/reference/storages.rst index fb5d322..c7db380 100644 --- a/doc/reference/storages.rst +++ b/doc/reference/storages.rst @@ -10,6 +10,9 @@ resources in memory. .. autoclass:: scim2_server.storage.AsyncScimStorage :members: +.. autoclass:: scim2_server.storage.SearchPage + :members: + .. autoclass:: scim2_server.memory.InMemoryStorage :members: generate_id, next_version, operation diff --git a/pyproject.toml b/pyproject.toml index 35d1134..23913ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ requires-python = ">= 3.11" dependencies = [ - "scim2-models>=0.12.1", + "scim2-models>=0.12.2", ] [project.optional-dependencies] @@ -39,6 +39,9 @@ testing = [ precis = [ "precis-i18n>=1.1.2", ] +cursor = [ + "cryptography>=50.0.1", +] [project.urls] repository = "https://github.com/python-scim/scim2-server" @@ -61,6 +64,7 @@ dev = [ "scim2-client>=0.12.0", "scim2-tester>=0.5.1", "werkzeug>=3.0.3", + "cryptography>=50.0.1", ] doc = [ "shibuya>=2024.5.15", @@ -68,6 +72,7 @@ doc = [ "sphinx-argparse>=0.6.1", "sphinx-design>=0.6.1", "sphinx-issues>=5.0.0", + "sphinxcontrib-mermaid>=2.1.1", ] [tool.uv] diff --git a/scim2_server/applications/asgi.py b/scim2_server/applications/asgi.py index 65ca6ee..f0e940c 100644 --- a/scim2_server/applications/asgi.py +++ b/scim2_server/applications/asgi.py @@ -73,7 +73,9 @@ async def read_request(self, scope: Scope, receive: Receive) -> ScimRequest: method=scope["method"], base_url=self.get_base_url(scope), path=self.split_path(self.get_path(scope)), - query=dict(parse_qsl(scope["query_string"].decode())), + query=dict( + parse_qsl(scope["query_string"].decode(), keep_blank_values=True) + ), headers=[ (name.decode(), value.decode()) for name, value in scope["headers"] ], diff --git a/scim2_server/applications/base.py b/scim2_server/applications/base.py index 303b8be..c1fda2a 100644 --- a/scim2_server/applications/base.py +++ b/scim2_server/applications/base.py @@ -1,4 +1,5 @@ import logging +import os from typing import Any from scim2_models import SCIMException @@ -19,12 +20,18 @@ class BaseApplication: :param service: The service serving the requests, built upon ``provider``. Pass a subclass of :class:`~scim2_server.service.ScimService` to change one of its steps, such as the URL of the resources. A - :class:`~scim2_server.service.ScimService` of ``provider`` by default. + :class:`~scim2_server.service.ScimService` of ``provider`` by default, + whose secret comes from the ``SCIM2_SERVER_SECRET`` environment + variable. """ def __init__(self, provider: ScimProvider, service: ScimService | None = None): self.provider = provider - self.service = service if service is not None else ScimService(provider) + self.service = ( + service + if service is not None + else ScimService(provider, secret=os.environ.get("SCIM2_SERVER_SECRET")) + ) self.log = logging.getLogger("scim2_server") @staticmethod diff --git a/scim2_server/applications/wsgi.py b/scim2_server/applications/wsgi.py index 57bdc00..64ef0bb 100644 --- a/scim2_server/applications/wsgi.py +++ b/scim2_server/applications/wsgi.py @@ -81,7 +81,9 @@ def read_request(self, environ: "WSGIEnvironment") -> ScimRequest: method=environ["REQUEST_METHOD"], base_url=self.get_base_url(environ), path=self.split_path(environ.get("PATH_INFO", "")), - query=dict(parse_qsl(environ.get("QUERY_STRING", ""))), + query=dict( + parse_qsl(environ.get("QUERY_STRING", ""), keep_blank_values=True) + ), headers=headers, ) request.body = self.read_body(environ, request) diff --git a/scim2_server/cursor.py b/scim2_server/cursor.py new file mode 100644 index 0000000..e8c476d --- /dev/null +++ b/scim2_server/cursor.py @@ -0,0 +1,107 @@ +"""The cursors of :rfc:`RFC 9865 <9865>`, which hide the position of a page from the client. + +This module needs the ``cursor`` extra, which installs ``cryptography``. +""" + +import base64 +import json +import os +import time +from typing import Any + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from scim2_models import ExpiredCursorException +from scim2_models import InvalidCountException +from scim2_models import InvalidCursorException + +VERSION = b"\x01" +SALT_SIZE = 16 +NONCE_SIZE = 12 +TAG_SIZE = 16 +HEADER_SIZE = len(VERSION) + SALT_SIZE + NONCE_SIZE + + +class Cursors: + """Seal the position of a page into a cursor, and open the cursor back. + + A cursor is encrypted and authenticated with AES-GCM, so the client can + neither read nor forge it (:rfc:`RFC 9865 §5.2 <9865#section-5.2>`). It + holds the query it belongs to, the ``count`` of the query, and the time it + was issued. + + Each cursor has its own key, derived from the secret and a random salt. + The number of cursors a secret seals is then not bounded by the number of + messages AES-GCM allows for a single key. + + :param secret: The secret the keys of the cursors derive from. Every + process of the server needs the same secret. + """ + + def __init__(self, secret: str | bytes): + self.secret = secret.encode() if isinstance(secret, str) else secret + + def _aead(self, salt: bytes) -> AESGCM: + """Return the cipher of the cursors sealed with a salt.""" + key = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + info=b"scim2-server cursor", + ).derive(self.secret) + return AESGCM(key) + + def seal(self, query: str, count: int | None, position: Any) -> str: + """Return the cursor of a position, for the query and the count it belongs to.""" + content = json.dumps( + {"q": query, "n": count, "t": int(time.time()), "p": position}, + separators=(",", ":"), + ).encode() + salt = os.urandom(SALT_SIZE) + nonce = os.urandom(NONCE_SIZE) + sealed = self._aead(salt).encrypt(nonce, content, VERSION) + token = VERSION + salt + nonce + sealed + return base64.urlsafe_b64encode(token).rstrip(b"=").decode() + + def open( + self, cursor: str, query: str, count: int | None, timeout: int | None + ) -> Any: + """Return the position a cursor holds. + + :param timeout: The number of seconds a cursor stays valid, if any. + :raises ~scim2_models.InvalidCursorException: When the cursor was not + issued by this server, or for another query. + :raises ~scim2_models.InvalidCountException: When the cursor was + issued for another ``count``. + :raises ~scim2_models.ExpiredCursorException: When the cursor is + older than ``timeout``. + """ + try: + token = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) + except ValueError: + raise InvalidCursorException from None + + if token[:1] != VERSION or len(token) < HEADER_SIZE + TAG_SIZE: + raise InvalidCursorException + + salt = token[len(VERSION) : len(VERSION) + SALT_SIZE] + nonce = token[len(VERSION) + SALT_SIZE : HEADER_SIZE] + try: + content = json.loads( + self._aead(salt).decrypt(nonce, token[HEADER_SIZE:], VERSION) + ) + except InvalidTag: + raise InvalidCursorException from None + + if content["q"] != query: + raise InvalidCursorException + + if content["n"] != count: + raise InvalidCountException + + if timeout is not None and time.time() - content["t"] > timeout: + raise ExpiredCursorException + + return content["p"] diff --git a/scim2_server/handler.py b/scim2_server/handler.py index 91dd5d4..10834aa 100644 --- a/scim2_server/handler.py +++ b/scim2_server/handler.py @@ -2,6 +2,7 @@ from typing import cast from scim2_models import BulkOperation +from scim2_models import Pagination from scim2_models import PatchOp from scim2_models import Resource from scim2_models import ResourceType @@ -15,7 +16,21 @@ from scim2_server.service import ScimService from scim2_server.storage import AsyncScimStorage from scim2_server.storage import ScimStorage +from scim2_server.storage import async_search_page from scim2_server.storage import projection +from scim2_server.storage import search_page + + +def _check_cursors( + service: ScimService, storage: ScimStorage | AsyncScimStorage +) -> None: + """Refuse a storage without cursors, when the configuration of the service announces them.""" + supported = storage.supports_cursors and storage._search_takes_position + if service._pages_by(Pagination.DefaultPaginationMethod.cursor) and not supported: + raise TypeError( + f"The configuration announces cursor pagination, which " + f"{type(storage).__qualname__} does not support." + ) class ScimHandler: @@ -28,9 +43,14 @@ class ScimHandler: :class:`~scim2_models.SCIMException`, that :meth:`ScimService.error_response ` turns into a response. Any other exception is a bug. + + :raises TypeError: When the configuration of the service announces + cursor pagination, and the storage does not + :attr:`~scim2_server.storage.ScimStorage.supports_cursors`. """ def __init__(self, service: ScimService, storage: ScimStorage): + _check_cursors(service, storage) self.service = service self.storage = storage @@ -205,13 +225,15 @@ def search(self, request: ScimRequest) -> ScimResponse: authorized_types = self.service.authorized_types( request, target, resource_types ) - search_request = self.service.read_search_query( + search_request, position = self.service.read_search_query( resource_types, request.query ) with self.storage.operation(): - total, resources = self.storage.search(authorized_types, search_request) + page = search_page( + self.storage, authorized_types, search_request, position=position + ) return self.service.search_response( - request.base_url, total, resources, search_request + request.base_url, resource_types, page, search_request ) def search_with_body(self, request: ScimRequest) -> ScimResponse: @@ -222,13 +244,15 @@ def search_with_body(self, request: ScimRequest) -> ScimResponse: authorized_types = self.service.authorized_types( request, target, resource_types ) - search_request = self.service.read_search_body( + search_request, position = self.service.read_search_body( resource_types, request.body, request.header("Content-Type") ) with self.storage.operation(): - total, resources = self.storage.search(authorized_types, search_request) + page = search_page( + self.storage, authorized_types, search_request, position=position + ) return self.service.search_response( - request.base_url, total, resources, search_request + request.base_url, resource_types, page, search_request ) # -- Bulk ----------------------------------------------------------- @@ -353,9 +377,14 @@ class AsyncScimHandler: It calls the same steps of the same service, and awaits the storage between them. + + :raises TypeError: When the configuration of the service announces + cursor pagination, and the storage does not + :attr:`~scim2_server.storage.AsyncScimStorage.supports_cursors`. """ def __init__(self, service: ScimService, storage: AsyncScimStorage): + _check_cursors(service, storage) self.service = service self.storage = storage @@ -536,15 +565,15 @@ async def search(self, request: ScimRequest) -> ScimResponse: authorized_types = self.service.authorized_types( request, target, resource_types ) - search_request = self.service.read_search_query( + search_request, position = self.service.read_search_query( resource_types, request.query ) async with self.storage.operation(): - total, resources = await self.storage.search( - authorized_types, search_request + page = await async_search_page( + self.storage, authorized_types, search_request, position=position ) return self.service.search_response( - request.base_url, total, resources, search_request + request.base_url, resource_types, page, search_request ) async def search_with_body(self, request: ScimRequest) -> ScimResponse: @@ -555,15 +584,15 @@ async def search_with_body(self, request: ScimRequest) -> ScimResponse: authorized_types = self.service.authorized_types( request, target, resource_types ) - search_request = self.service.read_search_body( + search_request, position = self.service.read_search_body( resource_types, request.body, request.header("Content-Type") ) async with self.storage.operation(): - total, resources = await self.storage.search( - authorized_types, search_request + page = await async_search_page( + self.storage, authorized_types, search_request, position=position ) return self.service.search_response( - request.base_url, total, resources, search_request + request.base_url, resource_types, page, search_request ) # -- Bulk ----------------------------------------------------------- diff --git a/scim2_server/memory.py b/scim2_server/memory.py index 64fd424..cb85312 100644 --- a/scim2_server/memory.py +++ b/scim2_server/memory.py @@ -10,6 +10,7 @@ from typing import cast from scim2_models import AttributeBinding +from scim2_models import InvalidCursorException from scim2_models import Meta from scim2_models import NotFoundException from scim2_models import Path @@ -24,7 +25,9 @@ from scim2_server.storage import AsyncScimStorage from scim2_server.storage import ScimStorage +from scim2_server.storage import SearchPage from scim2_server.storage import projection +from scim2_server.storage import search_page from scim2_server.utils import parametrize @@ -33,6 +36,112 @@ def utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.UTC) +def _page( + search_request: SearchRequest[Any], position: Any, resources: list[Resource[Any]] +) -> SearchPage: + """Sort every matching resource by its sort value then its identifier, and return the page a search asks for. + + A cursor position holds the sort value and the identifier of the resource + the page starts after, or ends before, so the pages stay stable. + """ + descending = ( + search_request.sort_by is not None + and search_request.sort_order == SearchRequest.SortOrder.descending + ) + pairs = sorted( + ((_sort_key(search_request, resource), resource) for resource in resources), + key=lambda pair: pair[0], + reverse=descending, + ) + found = [resource for _, resource in pairs] + if search_request.cursor is None: + start = search_request.start_index_0 or 0 + stop = None if search_request.count is None else start + search_request.count + return SearchPage(len(found), found[start:stop]) + + if search_request.count == 0: + return SearchPage(len(found), []) + + start, stop = _bounds( + [key for key, _ in pairs], position, search_request.count, descending + ) + page = SearchPage(len(found), found[start:stop]) + if page.resources and stop < len(found): + page.next = _position("next", pairs[stop - 1][0]) + if page.resources and start > 0: + page.previous = _position("previous", pairs[start][0]) + return page + + +SortKey = tuple[bool, Any, str] + + +def _sort_key(search_request: SearchRequest[Any], resource: Resource[Any]) -> SortKey: + """Return the key that orders a resource: its sort value, missing values last, then its identifier.""" + value = search_request.sort_value(resource) + return value is None, value if value is not None else "", cast(str, resource.id) + + +def _bounds( + keys: list[SortKey], position: Any, count: int | None, descending: bool +) -> tuple[int, int]: + """Return the slice of the sorted keys that a cursor page covers.""" + if position is None: + return 0, len(keys) if count is None else min(count, len(keys)) + + direction, anchor = _read_position(position) + + def before(key: SortKey) -> bool: + return key > anchor if descending else key < anchor + + if direction == "next": + start = sum(1 for key in keys if before(key) or key == anchor) + stop = len(keys) if count is None else min(start + count, len(keys)) + return start, stop + + stop = sum(1 for key in keys if before(key)) + start = 0 if count is None else max(stop - count, 0) + return start, stop + + +def _position(direction: str, key: SortKey) -> dict[str, Any]: + """Return the position of the page that starts after, or ends before, a sort key.""" + missing, value, identifier = key + return {"d": direction, "k": None if missing else _encode(value), "i": identifier} + + +def _read_position(position: Any) -> tuple[str, SortKey]: + """Return the direction and the sort key of a position that _page gave.""" + try: + direction, value, identifier = position["d"], position["k"], position["i"] + except (TypeError, KeyError): + raise InvalidCursorException from None + if direction not in ("next", "previous") or not isinstance(identifier, str): + raise InvalidCursorException + if value is None: + return direction, (True, "", identifier) + return direction, (False, _decode(value), identifier) + + +def _encode(value: Any) -> Any: + """Return a sort value in a form JSON can hold.""" + if isinstance(value, datetime.datetime): + return {"datetime": value.isoformat()} + if isinstance(value, str | bool | int | float): + return value + raise TypeError(f"A cursor cannot hold a sort value of type {type(value).__name__}") + + +def _decode(value: Any) -> Any: + """Return the sort value that _encode gave.""" + if not isinstance(value, dict): + return value + try: + return datetime.datetime.fromisoformat(value["datetime"]) + except (KeyError, TypeError, ValueError): + raise InvalidCursorException from None + + class InMemoryStorage(ScimStorage): """A storage keeping the resources in memory, for tests, debugging and demos. @@ -43,6 +152,8 @@ class InMemoryStorage(ScimStorage): ``meta.lastModified``. Pass a fixed clock to get predictable dates. """ + supports_cursors = True + def __init__(self, clock: Callable[[], datetime.datetime] = utcnow) -> None: self.resources: list[Resource[Any]] = [] self.clock = clock @@ -83,8 +194,12 @@ def get( ) def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: with self.lock: candidates = [ resource.model_copy(deep=True) @@ -98,13 +213,7 @@ def search( scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007 found = [r for r in candidates if scim_filter is None or scim_filter.match(r)] - found = search_request.sort(found) - - start_index = (search_request.start_index or 1) - 1 - page = found[start_index:] - if search_request.count is not None: - page = page[: search_request.count] - return len(found), page + return _page(search_request, position, found) def create( self, resource_type: ResourceType, resource: Resource[Any] @@ -236,6 +345,8 @@ class AsyncInMemoryStorage(AsyncScimStorage): :class:`InMemoryStorage` to change how identifiers are generated. """ + supports_cursors = True + def __init__(self, storage: InMemoryStorage | None = None) -> None: self.storage = storage if storage is not None else InMemoryStorage() @@ -258,9 +369,13 @@ async def get( ) async def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: - return self.storage.search(resource_types, search_request) + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: + return search_page(self.storage, resource_types, search_request, position) async def create( self, resource_type: ResourceType, resource: Resource[Any] diff --git a/scim2_server/service.py b/scim2_server/service.py index 0c94c59..ae95d45 100644 --- a/scim2_server/service.py +++ b/scim2_server/service.py @@ -1,6 +1,9 @@ +import hashlib +import json from collections.abc import Mapping from dataclasses import replace from http import HTTPStatus +from typing import TYPE_CHECKING from typing import Any from typing import TypeVar from typing import Union @@ -27,6 +30,7 @@ from scim2_models import Meta from scim2_models import NotFoundException from scim2_models import NotImplementedException +from scim2_models import Pagination from scim2_models import Patch from scim2_models import PatchOp from scim2_models import Path @@ -51,8 +55,12 @@ from scim2_server.routing import Operation from scim2_server.routing import Target from scim2_server.routing import match +from scim2_server.storage import SearchPage from scim2_server.utils import parametrize +if TYPE_CHECKING: + from scim2_server.cursor import Cursors + def scim_exception_of(exception: ValidationError) -> SCIMException: """Return the SCIM exception of an invalid request.""" @@ -87,6 +95,7 @@ def scim_exception_of(exception: ValidationError) -> SCIMException: "sortOrder", "startIndex", "count", + "cursor", ) ModelT = TypeVar("ModelT", bound=BaseModel) @@ -142,11 +151,43 @@ class ScimService: Every public method can be overridden. ``base_url`` is the root URL of the SCIM endpoints, as the client sees it, such as ``https://example.com/scim/v2``. + + :param secret: The secret of the service, which every process of the + server shares, such as a random string kept in the configuration. The + cursors of :rfc:`RFC 9865 <9865>` derive their key from it, so a + configuration that announces cursor pagination requires it, and the + ``cursor`` extra. + :raises ValueError: When the configuration announces cursor pagination + without a secret. + :raises ModuleNotFoundError: When the configuration announces cursor + pagination without the ``cursor`` extra. """ - def __init__(self, provider: ScimProvider): + def __init__( + self, provider: ScimProvider, *, secret: str | bytes | None = None + ) -> None: self.provider = provider self.config = provider.config or patch_only_config() + self._cursors = ( + self._build_cursors(secret) + if self._pages_by(Pagination.DefaultPaginationMethod.cursor) + else None + ) + + @staticmethod + def _build_cursors(secret: str | bytes | None) -> "Cursors": + """Return what seals and opens the cursors of the service.""" + if secret is None: + raise ValueError("Cursor pagination needs the secret of the service.") + try: + # Imported on demand, so that the server runs without the cursor extra. + from scim2_server.cursor import Cursors + except ModuleNotFoundError as exception: + raise ModuleNotFoundError( + "Cursor pagination needs the cursor extra: " + "pip install 'scim2-server[cursor]'" + ) from exception + return Cursors(secret) # -- Resource types and models -------------------------------------- @@ -686,8 +727,11 @@ def search_models( def read_search_query( self, resource_types: list[ResourceType], query: Mapping[str, str] - ) -> SearchRequest[Any]: - """Read the query parameters of a search with GET (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`).""" + ) -> tuple[SearchRequest[Any], Any]: + """Read the query parameters of a search with GET (:rfc:`RFC 7644 §3.4.2 <7644#section-3.4.2>`). + + See :meth:`read_search` for what it returns. + """ return self.read_search( resource_types, {key: query[key] for key in SEARCH_REQUEST_PARAMETERS if key in query}, @@ -698,20 +742,43 @@ def read_search_body( resource_types: list[ResourceType], body: bytes, content_type: str | None, - ) -> SearchRequest[Any]: - """Read the body of a search with POST on ".search" (:rfc:`RFC 7644 §3.4.3 <7644#section-3.4.3>`).""" + ) -> tuple[SearchRequest[Any], Any]: + """Read the body of a search with POST on ".search" (:rfc:`RFC 7644 §3.4.3 <7644#section-3.4.3>`). + + See :meth:`read_search` for what it returns. + """ return self.read_search(resource_types, self.decode_body(body, content_type)) def read_search( self, resource_types: list[ResourceType], payload: Any - ) -> SearchRequest[Any]: - """Validate a search request, and bound its count by maxResults.""" + ) -> tuple[SearchRequest[Any], Any]: + """Validate a search request, and return it with the position of the page its cursor asks for. + + The search pages by cursor or by index, as the request asks, or else + as the ``pagination`` of the configuration says. Its ``count`` is + bounded by ``maxResults`` and ``maxPageSize``, and is + ``defaultPageSize`` when the request has none. + + A search paged by cursor keeps an empty + :attr:`~scim2_models.SearchRequest.cursor`, which the storage expects. + The position is :data:`None` on the first page. + + :raises ~scim2_models.InvalidCursorException: When the cursor was not + issued by this service, or for another query. + :raises ~scim2_models.InvalidCountException: When the cursor was + issued for another ``count``. + :raises ~scim2_models.ExpiredCursorException: When the cursor is + older than the ``cursorTimeout`` of the configuration. + :raises ~scim2_models.NotImplementedException: When the configuration + does not announce the pagination method of the request. + """ # The filters of PATCH paths are part of the PATCH capability: the # filter capability of RFC 7643 §5 refers to the search parameter of # RFC 7644 §3.4.2.2 only. parameters = ( {key.casefold() for key in payload} if isinstance(payload, dict) else set() ) + if "filter" in parameters: self.ensure_supported(self.config.filter, "Filtering") if parameters & {"sortby", "sortorder"}: @@ -724,13 +791,129 @@ def read_search( ).model_validate(payload, scim_ctx=Context.SEARCH_REQUEST) except ValidationError as exception: raise scim_exception_of(exception) from exception - search_request.start_index = search_request.start_index or 1 - max_results = self.config.filter.max_results if self.config.filter else None - if max_results is not None and ( - search_request.count is None or search_request.count > max_results + + if search_request.cursor is not None and search_request.start_index is not None: + raise InvalidValueException( + detail="A search cannot have both a cursor and a startIndex" + ) + + if ( + search_request.cursor is None + and search_request.start_index is None + and self._default_pagination() == Pagination.DefaultPaginationMethod.cursor ): - search_request.count = max_results - return search_request + search_request.cursor = "" + + if search_request.cursor is None: + self._ensure_pages_by(Pagination.DefaultPaginationMethod.index) + search_request.start_index = search_request.start_index or 1 + else: + self._ensure_pages_by(Pagination.DefaultPaginationMethod.cursor) + + self._bound_count(search_request) + return search_request, self._open_cursor(resource_types, search_request) + + def _pages_by(self, method: Pagination.DefaultPaginationMethod) -> bool: + """Tell whether the configuration announces a pagination method. + + Without pagination in the configuration, the service pages by index + only. + """ + pagination = self.config.pagination + if pagination is None: + return method == Pagination.DefaultPaginationMethod.index + return bool(getattr(pagination, method.value)) + + def _ensure_pages_by(self, method: Pagination.DefaultPaginationMethod) -> None: + """Refuse with a 501 a pagination method the configuration does not announce.""" + if not self._pages_by(method): + raise NotImplementedException( + detail=f"Pagination by {method.value} is not supported" + ) + + def _default_pagination(self) -> Pagination.DefaultPaginationMethod: + """Return the pagination method of a search that asks for none. + + It is the defaultPaginationMethod of the configuration. Without it, + the index is the default when the configuration announces it, as RFC + 9865 §2.4 recommends. + """ + pagination = self.config.pagination + if pagination is not None and pagination.default_pagination_method: + return pagination.default_pagination_method + if self._pages_by(Pagination.DefaultPaginationMethod.index): + return Pagination.DefaultPaginationMethod.index + return Pagination.DefaultPaginationMethod.cursor + + def _bound_count(self, search_request: SearchRequest[Any]) -> None: + """Give a search without count the defaultPageSize, and bound its count by maxResults and maxPageSize.""" + pagination = self.config.pagination + if search_request.count is None and pagination is not None: + search_request.count = pagination.default_page_size + + bounds = [ + bound + for bound in ( + self.config.filter.max_results if self.config.filter else None, + pagination.max_page_size if pagination else None, + ) + if bound is not None + ] + if bounds and ( + search_request.count is None or search_request.count > min(bounds) + ): + search_request.count = min(bounds) + + def _open_cursor( + self, resource_types: list[ResourceType], search_request: SearchRequest[Any] + ) -> Any: + """Return the position a cursor holds, or None for the first page, and leave an empty cursor in the request.""" + if not search_request.cursor: + return None + + assert self._cursors is not None + assert self.config.pagination is not None + + position = self._cursors.open( + search_request.cursor, + self._query_digest(resource_types, search_request), + search_request.count, + self.config.pagination.cursor_timeout, + ) + search_request.cursor = "" + return position + + def _seal( + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + position: Any, + ) -> str | None: + """Return the cursor of a position the storage gave, or None when there is no such page.""" + if position is None: + return None + assert self._cursors is not None + return self._cursors.seal( + self._query_digest(resource_types, search_request), + search_request.count, + position, + ) + + @staticmethod + def _query_digest( + resource_types: list[ResourceType], search_request: SearchRequest[Any] + ) -> str: + """Return a digest of a search, without its cursor and its count, so that a cursor only serves the query it was issued for.""" + query = { + "resourceTypes": [resource_type.id for resource_type in resource_types], + "request": search_request.model_dump( + mode="json", + exclude={"cursor", "count", "start_index"}, + exclude_none=True, + ), + } + canonical = json.dumps(query, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode()).hexdigest()[:32] def searched_types(self, endpoint: str | None) -> list[ResourceType]: """Return the resource types a search covers: those of the endpoint, or all of them at the root.""" @@ -741,22 +924,35 @@ def searched_types(self, endpoint: str | None) -> list[ResourceType]: def search_response( self, base_url: str, - total_results: int, - resources: list[Resource[Any]], + resource_types: list[ResourceType], + page: SearchPage, search_request: SearchRequest[Any], ) -> ScimResponse: - """Return the response listing a page of found resources.""" + """Return the response listing a page of found resources. + + :raises ValueError: When the storage gives no total for a search paged + by index. + """ + by_cursor = search_request.cursor is not None + if not by_cursor and page.total is None: + raise ValueError("The storage gave no total for a search paged by index") + next_cursor = previous_cursor = None + if by_cursor and search_request.count != 0: + next_cursor = self._seal(resource_types, search_request, page.next) + previous_cursor = self._seal(resource_types, search_request, page.previous) dumped = [ self.publish(base_url, resource).model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, response_parameters=search_request, ) - for resource in resources + for resource in page.resources ] response = parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007 - total_results=total_results, + total_results=page.total, items_per_page=len(dumped), - start_index=search_request.start_index, + start_index=None if by_cursor else search_request.start_index, + next_cursor=next_cursor, + previous_cursor=previous_cursor, resources=dumped, ) return ScimResponse( diff --git a/scim2_server/storage.py b/scim2_server/storage.py index 5ce34bc..e9818e3 100644 --- a/scim2_server/storage.py +++ b/scim2_server/storage.py @@ -5,7 +5,9 @@ from contextlib import AbstractAsyncContextManager from contextlib import AbstractContextManager from contextlib import nullcontext +from dataclasses import dataclass from typing import Any +from typing import cast from scim2_models import Resource from scim2_models import ResourceType @@ -13,20 +15,19 @@ from scim2_models import SearchRequest -def takes_response_parameters(storage_class: type) -> bool: - """Tell whether the ``get`` method of a storage accepts ``response_parameters``, and warn when it does not.""" - parameters = inspect.signature(storage_class.get).parameters.values() # type: ignore[attr-defined] +def _accepts(storage_class: type, method: str, parameter: str) -> bool: + """Tell whether a method of a storage accepts a keyword argument, and warn when it does not.""" + parameters = inspect.signature(getattr(storage_class, method)).parameters.values() if any( - parameter.name == "response_parameters" - or parameter.kind is inspect.Parameter.VAR_KEYWORD - for parameter in parameters + candidate.name == parameter or candidate.kind is inspect.Parameter.VAR_KEYWORD + for candidate in parameters ): return True warnings.warn( - f"{storage_class.__qualname__}.get should accept a response_parameters " + f"{storage_class.__qualname__}.{method} should accept a {parameter} " "keyword argument. scim2-server 0.9 will require it.", DeprecationWarning, - # takes_response_parameters, __init_subclass__, ABCMeta.__new__, then the class statement + # _accepts, __init_subclass__, ABCMeta.__new__, then the class statement stacklevel=4, ) return False @@ -42,6 +43,70 @@ def projection( return {"response_parameters": response_parameters} +@dataclass +class SearchPage: + """A page of found resources, as :meth:`ScimStorage.search` returns it.""" + + total: int | None + """The number of matching resources, on every page. + + It may be :data:`None` when the resources are paged with a cursor + (:rfc:`RFC 9865 §2 <9865#section-2>`). + """ + + resources: list[Resource[Any]] + """The resources of the page.""" + + next: Any = None + """The position of the next page, or :data:`None` on the last page.""" + + previous: Any = None + """The position of the previous page, or :data:`None` on the first page.""" + + +def search_page( + storage: "ScimStorage", + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + position: Any, +) -> SearchPage: + """Search a storage, whether its search method accepts a position or not. + + This function goes away with the deprecated search methods in + scim2-server 0.9. + """ + if storage._search_takes_position: + result = storage.search(resource_types, search_request, position=position) + else: + result = storage.search(resource_types, search_request) + return _page_of(result) + + +async def async_search_page( + storage: "AsyncScimStorage", + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + position: Any, +) -> SearchPage: + """Search an asynchronous storage, as search_page does. + + This function goes away with the deprecated search methods in + scim2-server 0.9. + """ + if storage._search_takes_position: + result = await storage.search(resource_types, search_request, position=position) + else: + result = await storage.search(resource_types, search_request) + return _page_of(result) + + +def _page_of(result: Any) -> SearchPage: + """Return the page a search returned, as a deprecated search returns the number of resources and the page.""" + if isinstance(result, tuple): + return SearchPage(*result) + return cast(SearchPage, result) + + class ScimStorage(ABC): """Where a SCIM server reads and writes its resources. @@ -72,11 +137,21 @@ class ScimStorage(ABC): calling the storage. """ + supports_cursors: bool = False + """Whether :meth:`search` pages the resources with cursors that give stable pages. + + Set it to :data:`True` once :meth:`search` follows the rules of the + cursors. The server refuses to start when its configuration announces + cursor pagination and the storage does not support it. + """ + _get_takes_response_parameters = True + _search_takes_position = True def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) - cls._get_takes_response_parameters = takes_response_parameters(cls) + cls._get_takes_response_parameters = _accepts(cls, "get", "response_parameters") + cls._search_takes_position = _accepts(cls, "search", "position") @abstractmethod def get( @@ -107,9 +182,13 @@ def get( @abstractmethod def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: - """Return the number of matching resources, and one page of them. + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: + """Return one page of the matching resources, and their number. The search request is already validated, and its ``count`` is already bounded by the ``maxResults`` of the server. The storage filters, sorts @@ -119,9 +198,32 @@ def search( An attribute that a resource type does not declare matches none of its resources (:rfc:`RFC 7644 §3.4.2.1 <7644#section-3.4.2.1>`). + A :attr:`~scim2_models.SearchRequest.start_index` pages the resources + by index. A :attr:`~scim2_models.SearchRequest.cursor` that is not + :data:`None` pages them with a cursor (:rfc:`RFC 9865 <9865>`), when + the storage :attr:`supports_cursors`. The storage then returns the + ``next`` and ``previous`` positions of the page, as values that JSON + can hold. The server hides them in the cursors it gives the client, + and passes them back as ``position``. ``position`` is :data:`None` on + the first page. + + The cursors give stable pages. A position holds the sort value and + the identifier of the last resource of the page, or of the first one + for the previous page. A resource that exists during the whole + paging, and whose sort value does not change, is returned exactly + once. A resource created or deleted meanwhile is returned or not, + depending on where it sorts. + + A ``search`` method without ``position`` is deprecated, and raises a + :class:`DeprecationWarning`. It returns the number of matching + resources and the page, and pages by index only. scim2-server 0.9 will + require ``position``. + :raises ~scim2_models.InvalidFilterException: When the storage cannot evaluate the filter. Filtering the page afterwards would make ``totalResults`` and the paging wrong. + :raises ~scim2_models.InvalidCursorException: When the storage cannot + read ``position``. :raises ~scim2_models.NotImplementedException: When the storage does not support searching several resource types at once. """ @@ -192,11 +294,16 @@ class AsyncScimStorage(ABC): which :class:`~scim2_server.testing.AsyncScimStorageContract` checks. """ + supports_cursors: bool = False + """Whether :meth:`search` pages the resources with cursors that give stable pages. See :attr:`ScimStorage.supports_cursors`.""" + _get_takes_response_parameters = True + _search_takes_position = True def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) - cls._get_takes_response_parameters = takes_response_parameters(cls) + cls._get_takes_response_parameters = _accepts(cls, "get", "response_parameters") + cls._search_takes_position = _accepts(cls, "search", "position") @abstractmethod async def get( @@ -210,9 +317,13 @@ async def get( @abstractmethod async def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: - """Return the number of matching resources, and one page of them. See :meth:`ScimStorage.search`.""" + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: + """Return one page of the matching resources, and their number. See :meth:`ScimStorage.search`.""" @abstractmethod async def create( diff --git a/scim2_server/testing.py b/scim2_server/testing.py index f4a9afd..4199063 100644 --- a/scim2_server/testing.py +++ b/scim2_server/testing.py @@ -41,7 +41,10 @@ def storage(self): from scim2_server.storage import AsyncScimStorage from scim2_server.storage import ScimStorage +from scim2_server.storage import SearchPage +from scim2_server.storage import async_search_page from scim2_server.storage import projection +from scim2_server.storage import search_page from scim2_server.utils import load_default_provider from scim2_server.utils import parametrize @@ -55,7 +58,8 @@ class ScimStorageContract: ``provider`` fixture, which serves the default resource types of :rfc:`RFC 7643 <7643>` unless overridden. The tests of a feature that the configuration of the provider does not announce, such as sorting, are - skipped. + skipped. So are the tests of the cursors, unless the storage + :attr:`~scim2_server.storage.ScimStorage.supports_cursors`. """ supports_root_search: bool = True @@ -97,6 +101,16 @@ def search_request( """Build a search request on the given models, as the server builds it.""" return parametrize(SearchRequest, Union[tuple(models)])(**parameters) # noqa: UP007 + @staticmethod + def search( + storage: Any, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + position: Any = None, + ) -> Any: + """Search a storage as the server does, whether its search method accepts a position or not.""" + return search_page(storage, resource_types, search_request, position) + @staticmethod def require(supported: bool, feature: str) -> None: """Skip the current test when the service does not support a feature.""" @@ -389,19 +403,19 @@ def test_search_a_resource_type( self.create_users(storage, user_type, user_model, "alice", "bob") storage.create(group_type, group_model(display_name="admins")) - total, resources = storage.search( - [user_type], self.search_request([user_model]) - ) + page = self.search(storage, [user_type], self.search_request([user_model])) - assert total == 2 - assert sorted(r.user_name for r in resources) == ["alice", "bob"] + assert page.total == 2 + assert sorted(r.user_name for r in page.resources) == ["alice", "bob"] def test_search_returns_copies( self, storage: Any, user_type: ResourceType, user_model: Any ) -> None: """Changing a found resource does not change the stored one.""" (user,) = self.create_users(storage, user_type, user_model, "bjensen") - _, (found,) = storage.search([user_type], self.search_request([user_model])) + (found,) = self.search( + storage, [user_type], self.search_request([user_model]) + ).resources found.user_name = "changed" assert storage.get(user_type, user.id).user_name == "bjensen" @@ -412,12 +426,14 @@ def test_search_pages_the_results( """A page holds count resources from startIndex, and the total counts them all.""" self.create_users(storage, user_type, user_model, "alice", "bob", "carol") - total, resources = storage.search( - [user_type], self.search_request([user_model], start_index=2, count=1) + page = self.search( + storage, + [user_type], + self.search_request([user_model], start_index=2, count=1), ) - assert total == 3 - assert len(resources) == 1 + assert page.total == 3 + assert len(page.resources) == 1 def test_search_without_count_returns_every_resource( self, storage: Any, user_type: ResourceType, user_model: Any @@ -425,12 +441,10 @@ def test_search_without_count_returns_every_resource( """A search without count is not paged.""" self.create_users(storage, user_type, user_model, "alice", "bob", "carol") - total, resources = storage.search( - [user_type], self.search_request([user_model]) - ) + page = self.search(storage, [user_type], self.search_request([user_model])) - assert total == 3 - assert len(resources) == 3 + assert page.total == 3 + assert len(page.resources) == 3 def test_search_with_a_filter( self, @@ -443,13 +457,14 @@ def test_search_with_a_filter( self.require(self.announces(provider, "filter"), "filtering") self.create_users(storage, user_type, user_model, "alice", "bob", "carol") - total, resources = storage.search( + page = self.search( + storage, [user_type], self.search_request([user_model], filter='userName eq "bob"', count=10), ) - assert total == 1 - assert [r.user_name for r in resources] == ["bob"] + assert page.total == 1 + assert [r.user_name for r in page.resources] == ["bob"] def test_search_sorted( self, @@ -462,14 +477,15 @@ def test_search_sorted( self.require(self.announces(provider, "sort"), "sorting") self.create_users(storage, user_type, user_model, "bob", "carol", "alice") - _, resources = storage.search( + page = self.search( + storage, [user_type], self.search_request( [user_model], sort_by="userName", sort_order="descending", count=2 ), ) - assert [r.user_name for r in resources] == ["carol", "bob"] + assert [r.user_name for r in page.resources] == ["carol", "bob"] def test_search_at_the_root( self, @@ -484,12 +500,14 @@ def test_search_at_the_root( self.create_users(storage, user_type, user_model, "alice") storage.create(group_type, group_model(display_name="admins")) - total, resources = storage.search( - [user_type, group_type], self.search_request([user_model, group_model]) + page = self.search( + storage, + [user_type, group_type], + self.search_request([user_model, group_model]), ) - assert total == 2 - assert {r.meta.resource_type for r in resources} == { + assert page.total == 2 + assert {r.meta.resource_type for r in page.resources} == { user_type.name, group_type.name, } @@ -509,13 +527,240 @@ def test_search_at_the_root_on_an_attribute_some_types_lack( self.create_users(storage, user_type, user_model, "alice") storage.create(group_type, group_model(display_name="admins")) - total, resources = storage.search( + page = self.search( + storage, [user_type, group_type], self.search_request([user_model, group_model], filter="userName pr"), ) - assert total == 1 - assert resources[0].user_name == "alice" + assert page.total == 1 + assert page.resources[0].user_name == "alice" + + def walk( + self, + storage: Any, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + ) -> list[Any]: + """Return every page of a search paged with a cursor, from the first one.""" + self.require(storage.supports_cursors, "cursor pagination") + pages = [self.search(storage, resource_types, search_request)] + while pages[-1].next is not None: + pages.append( + self.search(storage, resource_types, search_request, pages[-1].next) + ) + return pages + + def walk_back( + self, + storage: Any, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + page: Any, + ) -> list[Any]: + """Return the pages before a page, from the closest one, following the previous cursors.""" + pages = [] + while page.previous is not None: + page = self.search(storage, resource_types, search_request, page.previous) + pages.append(page) + return pages + + @staticmethod + def ids(pages: list[Any]) -> list[str]: + return [resource.id for page in pages for resource in page.resources] + + def test_cursor_pages_every_resource_once( + self, storage: Any, user_type: ResourceType, user_model: Any + ) -> None: + """Following the next cursors returns every resource once, count by count.""" + names = ["alice", "bob", "carol", "dave", "erin"] + self.create_users(storage, user_type, user_model, *names) + + pages = self.walk( + storage, [user_type], self.search_request([user_model], cursor="", count=2) + ) + + assert [len(page.resources) for page in pages] == [2, 2, 1] + assert sorted(r.user_name for page in pages for r in page.resources) == names + assert pages[0].previous is None + assert {page.total for page in pages} <= {5, None} + + def test_cursor_pages_in_sort_order( + self, + storage: Any, + provider: ScimProvider, + user_type: ResourceType, + user_model: Any, + ) -> None: + """The pages of a sorted search follow each other in the sort order.""" + self.require(self.announces(provider, "sort"), "sorting") + self.create_users( + storage, user_type, user_model, "dave", "alice", "carol", "bob" + ) + + pages = self.walk( + storage, + [user_type], + self.search_request( + [user_model], + cursor="", + count=3, + sort_by="userName", + sort_order="descending", + ), + ) + + assert [r.user_name for page in pages for r in page.resources] == [ + "dave", + "carol", + "bob", + "alice", + ] + + def test_cursor_pages_equal_sort_values_once( + self, + storage: Any, + provider: ScimProvider, + user_type: ResourceType, + user_model: Any, + ) -> None: + """Resources with the same sort value, or without one, are returned once, even across pages.""" + self.require(self.announces(provider, "sort"), "sorting") + for user_name, nick_name in [ + ("alice", "twin"), + ("bob", None), + ("carol", "twin"), + ("dave", "twin"), + ("erin", None), + ]: + storage.create( + user_type, user_model(user_name=user_name, nick_name=nick_name) + ) + + pages = self.walk( + storage, + [user_type], + self.search_request([user_model], cursor="", count=2, sort_by="nickName"), + ) + + names = [r.user_name for page in pages for r in page.resources] + assert sorted(names[:3]) == ["alice", "carol", "dave"] + assert sorted(names[3:]) == ["bob", "erin"] + + def test_cursor_pages_back( + self, storage: Any, user_type: ResourceType, user_model: Any + ) -> None: + """Following the previous cursors from the last page returns the same pages, back to the first.""" + self.create_users( + storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin" + ) + search_request = self.search_request([user_model], cursor="", count=2) + forward = self.walk(storage, [user_type], search_request) + + backward = self.walk_back(storage, [user_type], search_request, forward[-1]) + + assert [[r.id for r in page.resources] for page in reversed(backward)] == [ + [r.id for r in page.resources] for page in forward[:-1] + ] + assert backward[-1].previous is None + + @pytest.mark.parametrize("deleted", ["last of the page", "first of the page"]) + def test_cursor_gives_stable_pages_when_a_resource_of_the_page_is_deleted( + self, storage: Any, user_type: ResourceType, user_model: Any, deleted: str + ) -> None: + """Deleting a resource already returned does not skip nor repeat the next ones.""" + self.create_users( + storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin" + ) + search_request = self.search_request([user_model], cursor="", count=2) + expected = self.ids(self.walk(storage, [user_type], search_request)) + first = self.search(storage, [user_type], search_request) + victim = first.resources[-1 if deleted == "last of the page" else 0] + + storage.delete(user_type, victim.id) + rest = [self.search(storage, [user_type], search_request, first.next)] + while rest[-1].next is not None: + rest.append( + self.search(storage, [user_type], search_request, rest[-1].next) + ) + + assert self.ids([first, *rest]) == expected + + def test_cursor_gives_stable_pages_when_a_resource_is_created( + self, storage: Any, user_type: ResourceType, user_model: Any + ) -> None: + """Creating a resource between two pages does not skip nor repeat the existing ones.""" + self.create_users( + storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin" + ) + search_request = self.search_request([user_model], cursor="", count=2) + expected = self.ids(self.walk(storage, [user_type], search_request)) + first = self.search(storage, [user_type], search_request) + + (created,) = self.create_users(storage, user_type, user_model, "frank") + rest = [self.search(storage, [user_type], search_request, first.next)] + while rest[-1].next is not None: + rest.append( + self.search(storage, [user_type], search_request, rest[-1].next) + ) + + returned = self.ids([first, *rest]) + assert [i for i in returned if i != created.id] == expected + + def test_cursor_pages_back_after_a_deletion( + self, storage: Any, user_type: ResourceType, user_model: Any + ) -> None: + """Deleting the first resource of a page does not change the page before it.""" + self.create_users( + storage, user_type, user_model, "alice", "bob", "carol", "dave", "erin" + ) + search_request = self.search_request([user_model], cursor="", count=2) + forward = self.walk(storage, [user_type], search_request) + + storage.delete(user_type, forward[1].resources[0].id) + previous = self.search( + storage, [user_type], search_request, forward[1].previous + ) + + assert self.ids([previous]) == self.ids([forward[0]]) + assert previous.previous is None + + def test_cursor_with_count_zero( + self, storage: Any, user_type: ResourceType, user_model: Any + ) -> None: + """A count of 0 returns no resource and no cursor, so the client does not loop.""" + self.require(storage.supports_cursors, "cursor pagination") + self.create_users(storage, user_type, user_model, "alice", "bob") + + page = self.search( + storage, [user_type], self.search_request([user_model], cursor="", count=0) + ) + + assert page.resources == [] + assert page.next is None + assert page.previous is None + assert page.total in (2, None) + + def test_cursor_at_the_root( + self, + storage: Any, + user_type: ResourceType, + group_type: ResourceType, + user_model: Any, + group_model: Any, + ) -> None: + """A cursor pages the resources of several resource types as one collection.""" + self.require(self.supports_root_search, "searching at the root") + users = self.create_users(storage, user_type, user_model, "alice", "bob") + group = storage.create(group_type, group_model(display_name="admins")) + + pages = self.walk( + storage, + [user_type, group_type], + self.search_request([user_model, group_model], cursor="", count=1), + ) + + assert sorted(self.ids(pages)) == sorted([users[0].id, users[1].id, group.id]) def test_operation( self, storage: Any, user_type: ResourceType, user_model: Any @@ -537,6 +782,7 @@ class BlockingStorage(ScimStorage): def __init__(self, storage: AsyncScimStorage, runner: asyncio.Runner) -> None: self.storage = storage self.runner = runner + self.supports_cursors = storage.supports_cursors def run(self, coroutine: Coroutine[Any, Any, T]) -> T: return self.runner.run(coroutine) @@ -557,9 +803,15 @@ def get( ) def search( - self, resource_types: list[ResourceType], search_request: SearchRequest[Any] - ) -> tuple[int, list[Resource[Any]]]: - return self.run(self.storage.search(resource_types, search_request)) + self, + resource_types: list[ResourceType], + search_request: SearchRequest[Any], + *, + position: Any = None, + ) -> SearchPage: + return self.run( + async_search_page(self.storage, resource_types, search_request, position) + ) def create( self, resource_type: ResourceType, resource: Resource[Any] diff --git a/scim2_server/testserver/cli.py b/scim2_server/testserver/cli.py index ba9c6b0..120bb9b 100644 --- a/scim2_server/testserver/cli.py +++ b/scim2_server/testserver/cli.py @@ -1,7 +1,9 @@ import argparse import json import logging +import os import pprint +import secrets from collections.abc import Iterable from socketserver import ThreadingMixIn from typing import TYPE_CHECKING @@ -17,6 +19,7 @@ from scim2_server.applications.wsgi import ForwardedHeaders from scim2_server.applications.wsgi import TenantDispatcher from scim2_server.memory import InMemoryStorage +from scim2_server.service import ScimService from scim2_server.testserver.application import BEARER_TOKEN_SCHEME from scim2_server.testserver.application import BearerTokenApplication from scim2_server.utils import load_default_resource_types @@ -78,7 +81,7 @@ def build_parser() -> argparse.ArgumentParser: type=argparse.FileType("r"), metavar="FILE", help="Announce the service provider configuration of a JSON file. " - "Defaults to every feature supported.", + "Defaults to every feature supported, except cursor pagination.", ) parser.add_argument( "--bearer-token", @@ -190,12 +193,26 @@ def main() -> None: schemas, resource_types, config=config, policy=policy ) + try: + # The server runs in a single process, so a secret drawn at start serves + # every cursor it issues. + secret = os.environ.get("SCIM2_SERVER_SECRET") or secrets.token_urlsafe(32) + service = ScimService(provider, secret=secret) + except ModuleNotFoundError: + parser.error( + "cursor pagination needs the cursor extra: " + "pip install 'scim2-server[cursor]'" + ) + storages: dict[str | None, InMemoryStorage] = {} def make_application(tenant: str | None = None) -> BearerTokenApplication: storages[tenant] = InMemoryStorage() return BearerTokenApplication( - storages[tenant], provider, bearer_tokens=args.bearer_token or [] + storages[tenant], + provider, + service=service, + bearer_tokens=args.bearer_token or [], ) def make_tenant_application(tenant: str) -> BearerTokenApplication | None: diff --git a/tests/conftest.py b/tests/conftest.py index 650960c..ea1202d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import asyncio import importlib.resources import json +import os import httpx2 import pytest @@ -14,6 +15,13 @@ from scim2_server.utils import load_default_resource_types from scim2_server.utils import load_default_schemas +SECRET = "a secret every worker shares" + +# The applications read the secret of their service from the environment, and +# so do the examples of the documentation, as a deployment does. +os.environ.setdefault("SCIM2_SERVER_SECRET", SECRET) +os.environ.setdefault("SCIM_SECRET", SECRET) + @pytest.fixture(scope="session") def scim_provider(): diff --git a/tests/test_application.py b/tests/test_application.py index 16bf01c..e6c95dd 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -8,6 +8,9 @@ from scim2_server.applications.wsgi import WSGIApplication from scim2_server.service import ScimService +from scim2_server.storage import SearchPage + +from .conftest import SECRET class TestApplication: @@ -118,7 +121,7 @@ def test_a_resource_of_an_unknown_type_from_the_storage(app, wsgi, first_fake_us (user,) = app.storage.resources user.meta.resource_type = "Unknown" - with patch.object(app.storage, "search", return_value=(1, [user])): + with patch.object(app.storage, "search", return_value=SearchPage(1, [user])): r = wsgi.get("/v2/Users") assert r.status_code == 500 @@ -132,7 +135,7 @@ def resource_location(self, base_url, resource_type, resource_id): return f"https://ids.example/{resource_type.id}/{resource_id}" app = WSGIApplication( - storage, scim_provider, service=ElsewhereService(scim_provider) + storage, scim_provider, service=ElsewhereService(scim_provider, secret=SECRET) ) with httpx2.Client( transport=httpx2.WSGITransport(app=app), base_url="https://scim.example.com" diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 222197c..33e4861 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -15,6 +15,8 @@ from scim2_server.routing import Target from scim2_server.service import ScimService +from .conftest import SECRET + BASE_URL = "https://scim.example/v2" JSON = {"Content-Type": "application/scim+json"} USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User" @@ -115,7 +117,7 @@ def user_names(handler): def test_every_operation_is_accepted_by_default(scim_provider): """A service that does not override authorize serves every operation.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) user_id = create_user(handler) diff --git a/tests/test_cursor.py b/tests/test_cursor.py new file mode 100644 index 0000000..8ad573e --- /dev/null +++ b/tests/test_cursor.py @@ -0,0 +1,535 @@ +import sys +from unittest.mock import patch + +import httpx2 +import pytest +import time_machine +from scim2_models import ExpiredCursorException +from scim2_models import InvalidCountException +from scim2_models import InvalidCursorException +from scim2_models import Pagination +from scim2_models import ScimProvider + +from scim2_server.applications.wsgi import WSGIApplication +from scim2_server.cursor import Cursors +from scim2_server.handler import AsyncScimHandler +from scim2_server.memory import AsyncInMemoryStorage +from scim2_server.memory import InMemoryStorage +from scim2_server.service import ScimService +from scim2_server.storage import SearchPage + +from .conftest import SECRET +from .conftest import BlockingHandler + + +def configured(scim_provider, **pagination): + """Return the default provider, with a pagination configuration.""" + config = scim_provider.config.model_copy( + update={"pagination": Pagination(**pagination)}, deep=True + ) + return ScimProvider( + models=scim_provider.models, + resource_types=scim_provider.resource_types, + config=config, + ) + + +@pytest.fixture(params=["sync", "async"]) +def client_with(request, storage, scim_provider): + """Build clients of applications paging with the given pagination configuration, served by the synchronous handler, then by the asynchronous one.""" + clients = [] + + def build(**pagination): + provider = configured(scim_provider, **pagination) + application = WSGIApplication(storage, provider) + if request.param == "async": + application.handler = BlockingHandler( + AsyncScimHandler(application.service, AsyncInMemoryStorage(storage)) + ) + client = httpx2.Client( + transport=httpx2.WSGITransport(app=application), + base_url="https://scim.example.com", + ) + clients.append(client) + for user_name in ("alice", "bob", "carol", "dave", "erin"): + client.post("/v2/Users", json={"userName": user_name}) + return client + + yield build + for client in clients: + client.close() + + +@pytest.fixture +def client(client_with): + return client_with(cursor=True, index=True) + + +def user_names(response): + return [user["userName"] for user in response.json().get("Resources", [])] + + +def walk(client, **parameters): + """Return every response of a search paged with a cursor, following nextCursor.""" + parameters = {"cursor": "", **parameters} + responses = [client.get("/v2/Users", params=parameters)] + while "nextCursor" in responses[-1].json(): + parameters["cursor"] = responses[-1].json()["nextCursor"] + responses.append(client.get("/v2/Users", params=parameters)) + return responses + + +def test_next_cursors_page_every_resource(client): + """Following nextCursor returns every resource once, with no startIndex.""" + responses = walk(client, count=2, sortBy="userName") + + assert [user_names(response) for response in responses] == [ + ["alice", "bob"], + ["carol", "dave"], + ["erin"], + ] + first = responses[0].json() + assert first["totalResults"] == 5 + assert first["itemsPerPage"] == 2 + assert "startIndex" not in first + assert "previousCursor" not in first + + +def test_previous_cursors_page_back_to_the_first_page(client): + """Following previousCursor from the last page returns the pages before it, up to the first one.""" + parameters = {"count": 2, "sortBy": "userName"} + pages = [walk(client, **parameters)[-1]] + while "previousCursor" in pages[-1].json(): + cursor = pages[-1].json()["previousCursor"] + pages.append(client.get("/v2/Users", params={"cursor": cursor, **parameters})) + + assert [user_names(page) for page in pages] == [ + ["erin"], + ["carol", "dave"], + ["alice", "bob"], + ] + assert "nextCursor" in pages[-1].json() + + +def test_an_empty_cursor_query_parameter_asks_for_the_first_page(client): + """A cursor parameter without a value asks for cursor pagination.""" + response = client.get("/v2/Users?cursor&count=2&sortBy=userName") + + assert user_names(response) == ["alice", "bob"] + assert "nextCursor" in response.json() + + +def test_cursors_are_url_safe(client): + """A cursor only uses the unreserved characters of RFC 3986 §2.3, without padding.""" + cursor = client.get("/v2/Users", params={"cursor": "", "count": 2}).json()[ + "nextCursor" + ] + + assert set(cursor) <= set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + ) + + +def test_a_search_with_body_pages_with_a_cursor(client): + """POST on .search takes a cursor too (RFC 9865 §3).""" + body = {"count": 3, "sortBy": "userName"} + first = client.post("/v2/Users/.search", json={"cursor": "", **body}) + second = client.post( + "/v2/Users/.search", + json={"cursor": first.json()["nextCursor"], **body}, + ) + + assert user_names(first) + user_names(second) == [ + "alice", + "bob", + "carol", + "dave", + "erin", + ] + + +def test_a_cursor_at_the_root(client): + """A cursor pages a search at the root of the server.""" + client.post("/v2/Groups", json={"displayName": "admins"}) + + first = client.get("/v2/", params={"cursor": "", "count": 4}) + second = client.get( + "/v2/", params={"cursor": first.json()["nextCursor"], "count": 4} + ) + + resources = first.json()["Resources"] + second.json()["Resources"] + assert len({resource["id"] for resource in resources}) == 6 + + +def test_a_count_of_zero_returns_no_cursor(client): + """With a count of 0, the response holds the total only, so the client does not loop.""" + response = client.get("/v2/Users", params={"cursor": "", "count": 0}) + + assert response.json()["totalResults"] == 5 + assert "nextCursor" not in response.json() + + +def error(response): + return response.status_code, response.json().get("scimType") + + +def test_a_cursor_and_a_start_index_are_refused(client): + """A search cannot ask for both pagination methods.""" + response = client.get("/v2/Users", params={"cursor": "", "startIndex": 1}) + + assert error(response) == (400, "invalidValue") + + +def test_a_null_cursor_with_a_start_index_pages_by_index(client): + """A cursor without value does not ask for cursor pagination.""" + response = client.post( + "/v2/Users/.search", + json={"cursor": None, "startIndex": 2, "count": 2, "sortBy": "userName"}, + ) + + assert response.json()["startIndex"] == 2 + assert user_names(response) == ["bob", "carol"] + + +def test_a_forged_cursor_is_refused(client): + """A cursor the server did not issue is invalid.""" + cursor = client.get("/v2/Users", params={"cursor": "", "count": 2}).json()[ + "nextCursor" + ] + forged = cursor[:-2] + ("AA" if cursor[-2:] != "AA" else "BB") + + response = client.get("/v2/Users", params={"cursor": forged, "count": 2}) + + assert error(response) == (400, "invalidCursor") + + +@pytest.mark.parametrize("cursor", ["garbage", "A", "AQ", "x.y~z"]) +def test_a_malformed_cursor_is_refused(client, cursor): + """A cursor that does not decode is invalid, and does not break the server.""" + response = client.get("/v2/Users", params={"cursor": cursor, "count": 2}) + + assert error(response) == (400, "invalidCursor") + + +@pytest.mark.parametrize( + "changed", + [ + {"sortBy": "displayName"}, + {"filter": 'userName ne "bob"'}, + {"attributes": "displayName"}, + ], +) +def test_a_cursor_of_another_query_is_refused(client, changed): + """The next pages must keep the parameters of the first one (RFC 9865 §2).""" + parameters = {"count": 2, "sortBy": "userName"} + cursor = client.get("/v2/Users", params={"cursor": "", **parameters}).json()[ + "nextCursor" + ] + + response = client.get( + "/v2/Users", params={"cursor": cursor, **parameters, **changed} + ) + + assert error(response) == (400, "invalidCursor") + + +def test_a_cursor_of_another_endpoint_is_refused(client): + """A cursor issued for the users does not page the groups.""" + cursor = client.get("/v2/Users", params={"cursor": "", "count": 2}).json()[ + "nextCursor" + ] + + response = client.get("/v2/Groups", params={"cursor": cursor, "count": 2}) + + assert error(response) == (400, "invalidCursor") + + +def test_a_cursor_with_another_count_is_refused(client): + """The count of the next pages must be the count of the first one (RFC 9865 §2.1).""" + cursor = client.get("/v2/Users", params={"cursor": "", "count": 2}).json()[ + "nextCursor" + ] + + response = client.get("/v2/Users", params={"cursor": cursor, "count": 3}) + + assert error(response) == (400, "invalidCount") + + +def test_a_cursor_of_another_secret_is_refused(client): + """A server only opens the cursors sealed with its own secret.""" + sealed = Cursors("another secret").seal("query", 2, 2) + + response = client.get("/v2/Users", params={"cursor": sealed, "count": 2}) + + assert error(response) == (400, "invalidCursor") + + +def test_an_old_cursor_expires_after_the_cursor_timeout(client_with): + """A cursor older than cursorTimeout is refused.""" + client = client_with(cursor=True, index=True, cursor_timeout=60) + with time_machine.travel(0, tick=False): + cursor = client.get("/v2/Users", params={"cursor": "", "count": 2}).json()[ + "nextCursor" + ] + with time_machine.travel(59, tick=False): + assert ( + client.get("/v2/Users", params={"cursor": cursor, "count": 2}).status_code + == 200 + ) + with time_machine.travel(61, tick=False): + response = client.get("/v2/Users", params={"cursor": cursor, "count": 2}) + + assert error(response) == (400, "expiredCursor") + + +def test_without_pagination_configuration_the_cursors_are_not_supported(wsgi): + """A server that does not announce cursor pagination refuses cursors with a 501.""" + response = wsgi.get("/v2/Users", params={"cursor": ""}) + + assert response.status_code == 501 + + +def test_an_index_the_configuration_does_not_announce_is_refused(client_with): + """A server that only announces cursors refuses startIndex with a 501.""" + client = client_with(cursor=True, index=False) + + response = client.get("/v2/Users", params={"startIndex": 1}) + + assert response.status_code == 501 + + +@pytest.mark.parametrize( + ("pagination", "cursor"), + [ + ({"cursor": True, "index": True}, False), + ({"cursor": True, "index": True, "default_pagination_method": "cursor"}, True), + ({"cursor": True, "index": True, "default_pagination_method": "index"}, False), + ({"cursor": True, "index": False}, True), + ], +) +def test_the_default_pagination_method(client_with, pagination, cursor): + """A search without pagination parameter follows defaultPaginationMethod, or else the index (RFC 9865 §2.4).""" + client = client_with(**pagination) + + response = client.get("/v2/Users", params={"count": 2}) + + assert ("nextCursor" in response.json()) is cursor + assert ("startIndex" in response.json()) is not cursor + + +def test_a_search_without_count_gets_the_default_page_size(client_with): + """Without count, a page holds defaultPageSize resources.""" + client = client_with(cursor=True, index=True, default_page_size=2) + + response = client.get("/v2/Users", params={"cursor": "", "sortBy": "userName"}) + + assert user_names(response) == ["alice", "bob"] + + +@pytest.mark.parametrize( + ("max_results", "pagination", "expected"), + [ + (1000, {"max_page_size": 3}, 3), + (2, {"max_page_size": 3}, 2), + (1000, {"max_page_size": 3, "default_page_size": 4}, 3), + ], +) +def test_the_count_is_bounded_by_the_smallest_limit( + storage, scim_provider, max_results, pagination, expected +): + """A page never holds more than maxResults nor maxPageSize resources, whichever is smaller.""" + provider = configured(scim_provider, cursor=False, index=True, **pagination) + provider.config.filter.max_results = max_results + application = WSGIApplication(storage, provider) + with httpx2.Client( + transport=httpx2.WSGITransport(app=application), + base_url="https://scim.example.com", + ) as client: + for user_name in ("alice", "bob", "carol", "dave", "erin"): + client.post("/v2/Users", json={"userName": user_name}) + + unbounded = client.get("/v2/Users") + bounded = client.get("/v2/Users", params={"count": 10}) + + assert len(unbounded.json()["Resources"]) == expected + assert len(bounded.json()["Resources"]) == expected + + +def test_a_deleted_resource_does_not_shift_the_next_page(client): + """Deleting the last resource of a page does not skip the first resource of the next page.""" + parameters = {"count": 2, "sortBy": "userName"} + first = client.get("/v2/Users", params={"cursor": "", **parameters}) + client.delete(f"/v2/Users/{first.json()['Resources'][-1]['id']}") + + second = client.get( + "/v2/Users", params={"cursor": first.json()["nextCursor"], **parameters} + ) + + assert user_names(second) == ["carol", "dave"] + + +def test_a_storage_without_cursors_is_refused_when_they_are_announced(scim_provider): + """A server whose configuration announces cursors does not start with a storage that does not support them.""" + + class IndexStorage(InMemoryStorage): + supports_cursors = False + + provider = configured(scim_provider, cursor=True, index=True) + + with pytest.raises(TypeError, match="IndexStorage"): + WSGIApplication(IndexStorage(), provider) + + +def test_a_storage_without_positions_is_refused_when_cursors_are_announced( + scim_provider, +): + """A storage whose search method predates the positions cannot page with cursors.""" + with pytest.warns(DeprecationWarning): + + class OldStorage(InMemoryStorage): + def search(self, resource_types, search_request): + raise NotImplementedError + + provider = configured(scim_provider, cursor=True, index=True) + + with pytest.raises(TypeError, match="OldStorage"): + WSGIApplication(OldStorage(), provider) + + +def test_an_async_storage_without_cursors_is_refused_when_they_are_announced( + scim_provider, +): + """The asynchronous handler refuses a storage without cursors too.""" + + class IndexStorage(AsyncInMemoryStorage): + supports_cursors = False + + provider = configured(scim_provider, cursor=True, index=True) + service = ScimService(provider, secret=SECRET) + + with pytest.raises(TypeError, match="IndexStorage"): + AsyncScimHandler(service, IndexStorage()) + + +def test_a_storage_without_cursors_serves_a_server_that_does_not_announce_them( + scim_provider, +): + """A storage without cursors serves a server that pages by index only.""" + + class IndexStorage(InMemoryStorage): + supports_cursors = False + + application = WSGIApplication(IndexStorage(), scim_provider) + + with httpx2.Client( + transport=httpx2.WSGITransport(app=application), + base_url="https://scim.example.com", + ) as client: + assert client.get("/v2/Users").status_code == 200 + + +def test_cursors_need_a_secret(scim_provider): + """A service that announces cursor pagination cannot work without a secret.""" + provider = configured(scim_provider, cursor=True, index=True) + + with pytest.raises(ValueError, match="secret"): + ScimService(provider) + + +def test_cursors_need_the_cursor_extra(scim_provider, monkeypatch): + """A service that announces cursor pagination tells which extra it needs.""" + monkeypatch.setitem(sys.modules, "scim2_server.cursor", None) + provider = configured(scim_provider, cursor=True, index=True) + + with pytest.raises(ModuleNotFoundError, match=r"scim2-server\[cursor\]"): + ScimService(provider, secret=SECRET) + + +def test_a_service_without_cursors_needs_no_secret(scim_provider, storage): + """A service that only pages by index has no cursor to seal, and needs no secret.""" + application = WSGIApplication(storage, scim_provider, ScimService(scim_provider)) + + with httpx2.Client( + transport=httpx2.WSGITransport(app=application), + base_url="https://scim.example.com", + ) as client: + assert client.get("/v2/Users").status_code == 200 + + +def test_a_cursor_opens_back_to_its_position(): + """A sealed position comes back intact, whatever its JSON shape.""" + cursors = Cursors(b"bytes work too") + position = {"d": "next", "k": "bob", "i": "7"} + + assert cursors.open(cursors.seal("query", 2, position), "query", 2, None) == ( + position + ) + + +@pytest.mark.parametrize( + ("query", "count", "exception"), + [ + ("other", 2, InvalidCursorException), + ("query", 3, InvalidCountException), + ], +) +def test_a_cursor_only_opens_for_its_query(query, count, exception): + """A cursor refuses a query or a count it was not issued for.""" + cursors = Cursors(SECRET) + + with pytest.raises(exception): + cursors.open(cursors.seal("query", 2, 0), query, count, None) + + +def test_a_cursor_expires(): + """A cursor older than the timeout is refused.""" + cursors = Cursors(SECRET) + with time_machine.travel(0, tick=False): + cursor = cursors.seal("query", 2, 0) + + with time_machine.travel(100, tick=False), pytest.raises(ExpiredCursorException): + cursors.open(cursor, "query", 2, 60) + + +def test_a_storage_may_leave_out_the_total_of_a_cursor_page(client, storage): + """With a cursor, a storage that cannot count the resources leaves totalResults out (RFC 9865 §2).""" + with patch.object(storage, "search", return_value=SearchPage(None, [])): + response = client.get("/v2/Users", params={"cursor": "", "count": 2}) + + assert response.status_code == 200 + assert "totalResults" not in response.json() + + +def test_a_page_by_index_needs_a_total(client, storage): + """By index, a storage that gives no total breaks the contract, and the server answers 500.""" + with patch.object(storage, "search", return_value=SearchPage(None, [])): + response = client.get("/v2/Users", params={"startIndex": 1}) + + assert response.status_code == 500 + + +def test_an_application_reads_the_secret_of_its_service_from_the_environment( + storage, scim_provider +): + """The default service of an application seals cursors with SCIM2_SERVER_SECRET.""" + provider = configured(scim_provider, cursor=True, index=True) + application = WSGIApplication(storage, provider) + + with httpx2.Client( + transport=httpx2.WSGITransport(app=application), + base_url="https://scim.example.com", + ) as client: + response = client.get("/v2/Users", params={"cursor": "", "count": 2}) + + assert response.status_code == 200 + + +def test_an_application_without_secret_refuses_the_cursors( + storage, scim_provider, monkeypatch +): + """Without SCIM2_SERVER_SECRET, an application that announces cursors cannot start.""" + monkeypatch.delenv("SCIM2_SERVER_SECRET") + provider = configured(scim_provider, cursor=True, index=True) + + with pytest.raises(ValueError, match="secret"): + WSGIApplication(storage, provider) diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py index 11b0cd6..e026563 100644 --- a/tests/test_doc_examples.py +++ b/tests/test_doc_examples.py @@ -9,12 +9,14 @@ from doc._examples import asgi_integration from doc._examples import wsgi_integration +from doc._examples.async_cursor_storage import AsyncCursorSQLiteStorage from doc._examples.async_library_storage import AsyncLibraryStorage from doc._examples.async_sqlite_storage import AsyncSQLiteStorage from doc._examples.contract_without_root_search import TestStorageWithoutRootSearch from doc._examples.contract_without_search_features import ( TestStorageWithoutFilterNorSort, ) +from doc._examples.cursor_storage import CursorSQLiteStorage from doc._examples.library_storage import BOOKS from doc._examples.library_storage import MEMBERS from doc._examples.library_storage import Book @@ -25,6 +27,7 @@ from scim2_server.applications.wsgi import WSGIApplication from scim2_server.testing import AsyncScimStorageContract from scim2_server.testing import ScimStorageContract +from scim2_server.utils import load_default_provider from scim2_server.utils import load_default_service_provider_config __all__ = ["TestStorageWithoutFilterNorSort", "TestStorageWithoutRootSearch"] @@ -50,6 +53,41 @@ def async_storage(self, provider): asyncio.run(storage.close()) +def provider_without_filter_nor_sort(): + provider = load_default_provider() + provider.config.filter.supported = False + provider.config.sort.supported = False + return provider + + +class TestCursorSQLiteStorage(ScimStorageContract): + """The cursor storage of the "Page the results with cursors" guide follows the storage contract.""" + + @pytest.fixture + def provider(self): + return provider_without_filter_nor_sort() + + @pytest.fixture + def storage(self, provider): + connection = sqlite3.connect(":memory:") + yield CursorSQLiteStorage(connection, provider) + connection.close() + + +class TestAsyncCursorSQLiteStorage(AsyncScimStorageContract): + """The asynchronous cursor storage of the "Page the results with cursors" guide follows the storage contract.""" + + @pytest.fixture + def provider(self): + return provider_without_filter_nor_sort() + + @pytest.fixture + def async_storage(self, provider): + storage = AsyncCursorSQLiteStorage(":memory:", provider) + yield storage + asyncio.run(storage.close()) + + USER = { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen", diff --git a/tests/test_me.py b/tests/test_me.py index ed4655e..e5c1e67 100644 --- a/tests/test_me.py +++ b/tests/test_me.py @@ -15,6 +15,8 @@ from scim2_server.requests import ScimRequest from scim2_server.service import ScimService +from .conftest import SECRET + BASE_URL = "https://scim.example/v2" JSON = {"Content-Type": "application/scim+json"} PATCH = json.dumps( @@ -127,7 +129,7 @@ def test_me_without_a_resource(handler, subject, status): @pytest.mark.parametrize("method", ["GET", "PUT", "PATCH", "DELETE", "POST"]) def test_me_is_not_implemented_by_default(scim_provider, method): """Without an override of the service, every method on /Me answers 501.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) assert serve(handler, me(method, ("User", "1"))).status == 501 @@ -156,4 +158,4 @@ def get_subject(self, request): def test_an_unknown_resource_type_name(scim_provider): """Looking up a resource type of an unknown name is a programming error.""" with pytest.raises(ValueError, match="No resource type named 'Device'"): - ScimService(scim_provider).get_resource_type("Device") + ScimService(scim_provider, secret=SECRET).get_resource_type("Device") diff --git a/tests/test_memory.py b/tests/test_memory.py index cf706e6..2704b71 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -20,6 +20,7 @@ from scim2_server.memory import AsyncInMemoryStorage from scim2_server.memory import InMemoryStorage +from scim2_server.storage import SearchPage from scim2_server.testing import AsyncScimStorageContract from scim2_server.testing import ScimStorageContract from scim2_server.utils import load_default_service_provider_config @@ -272,21 +273,18 @@ def test_a_filter_bound_to_no_model_is_resolved_against_the_stored_resources( storage.create(user_type, User(user_name=user_name)) resource_types = list(scim_provider.resource_types) if root else [user_type] - total, resources = storage.search( - resource_types, SearchRequest(filter='userName eq "bob"') - ) + page = storage.search(resource_types, SearchRequest(filter='userName eq "bob"')) - assert total == 1 - assert resources[0].user_name == "bob" + assert page.total == 1 + assert page.resources[0].user_name == "bob" def test_an_unbound_filter_without_resource(user_type): """With no stored resource, an unbound filter has nothing to be resolved against nor to match.""" storage = InMemoryStorage() - assert storage.search([user_type], SearchRequest(filter='userName eq "bob"')) == ( - 0, - [], - ) + assert storage.search( + [user_type], SearchRequest(filter='userName eq "bob"') + ) == SearchPage(0, []) def test_a_resource_type_named_apart_from_its_id(static_data): @@ -304,6 +302,6 @@ def test_a_resource_type_named_apart_from_its_id(static_data): assert created.meta.resource_type == "User" assert storage.get(resource_type, created.id).user_name == "bjensen" - assert storage.search([resource_type], SearchRequest())[0] == 1 + assert storage.search([resource_type], SearchRequest()).total == 1 with pytest.raises(UniquenessException): storage.create(resource_type, User(user_name="bjensen")) diff --git a/tests/test_references.py b/tests/test_references.py index 585fc6c..db1299e 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -13,6 +13,8 @@ from scim2_server.service import ScimService +from .conftest import SECRET + BASE_URL = "https://scim.example.com/v2" @@ -128,7 +130,10 @@ class Badge(Resource): def test_references_outside_complex_attributes_are_resolved(): """Single and multi-valued reference attributes are resolved, other references are kept.""" service = ScimService( - ScimProvider(models=[Badge], resource_types=[ResourceType.from_resource(Badge)]) + ScimProvider( + models=[Badge], resource_types=[ResourceType.from_resource(Badge)] + ), + secret=SECRET, ) badge = Badge( id="1", diff --git a/tests/test_routing.py b/tests/test_routing.py index 1bf6d77..07c24c2 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -10,6 +10,8 @@ from scim2_server.requests import ScimRequest from scim2_server.service import ScimService +from .conftest import SECRET + BASE_URL = "https://scim.example/v2" JSON = {"Content-Type": "application/scim+json"} SEARCH = b'{"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]}' @@ -31,7 +33,7 @@ def serve(handler, request): @pytest.fixture(params=["sync", "async"]) def handler(request, scim_provider): - service = ScimService(scim_provider) + service = ScimService(scim_provider, secret=SECRET) if request.param == "sync": return ScimHandler(service, InMemoryStorage()) return AsyncScimHandler(service, AsyncInMemoryStorage()) @@ -89,7 +91,7 @@ def test_a_method_the_endpoint_does_not_support_answers_405( def test_a_request_for_another_operation_is_a_routing_bug(scim_provider): """An operation method refuses a request that its route does not serve.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) with pytest.raises(RuntimeError, match="asks for create, not for query"): handler.query(scim_request("POST", "/Users")) @@ -110,14 +112,14 @@ def test_the_headers_are_case_insensitive(): def test_the_method_is_case_insensitive(scim_provider): """A request is routed whatever the case of its method.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) assert handler.handle(scim_request("get", "/Users")).status == 200 def test_a_request_can_be_completed(scim_provider): """A request built without body gets its body afterwards.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) request = scim_request("POST", "/Users", headers=JSON) request.body = b'{"userName": "bjensen"}' diff --git a/tests/test_service.py b/tests/test_service.py index 0fcf706..69d60f4 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,6 +19,8 @@ from scim2_server.service import is_json_media_type from scim2_server.utils import load_default_provider +from .conftest import SECRET + @pytest.mark.parametrize( ("content_type", "is_json"), @@ -41,7 +43,9 @@ def test_json_media_types(content_type, is_json): def test_error_response(scim_provider): """An exception becomes the SCIM error response of its status.""" - result = ScimService(scim_provider).error_response(NotFoundException(detail="Gone")) + result = ScimService(scim_provider, secret=SECRET).error_response( + NotFoundException(detail="Gone") + ) assert result.status == 404 assert result.body["detail"] == "Gone" @@ -50,7 +54,9 @@ def test_error_response(scim_provider): def test_unexpected_error_response(scim_provider): """An unexpected exception becomes a 500 that discloses nothing.""" - result = ScimService(scim_provider).error_response(RuntimeError("secret")) + result = ScimService(scim_provider, secret=SECRET).error_response( + RuntimeError("secret") + ) assert result.status == 500 assert result.body["detail"] == "Internal server error" @@ -61,7 +67,7 @@ def test_a_validation_error_is_unexpected(scim_provider): with pytest.raises(ValidationError) as excinfo: SearchRequest.model_validate({"count": "many"}) - result = ScimService(scim_provider).error_response(excinfo.value) + result = ScimService(scim_provider, secret=SECRET).error_response(excinfo.value) assert result.status == 500 @@ -91,7 +97,7 @@ def test_a_validation_error_is_unexpected(scim_provider): ) def test_an_invalid_request_raises_a_scim_exception(scim_provider, scim_request): """The handler turns the validation errors of a request into SCIM exceptions answering 400.""" - handler = ScimHandler(ScimService(scim_provider), InMemoryStorage()) + handler = ScimHandler(ScimService(scim_provider, secret=SECRET), InMemoryStorage()) with pytest.raises(SCIMException) as excinfo: handler.handle(scim_request) @@ -106,7 +112,9 @@ def test_an_invalid_request_raises_a_scim_exception_asynchronously( scim_provider, scim_request ): """The asynchronous handler turns the validation errors of a request into SCIM exceptions.""" - handler = AsyncScimHandler(ScimService(scim_provider), AsyncInMemoryStorage()) + handler = AsyncScimHandler( + ScimService(scim_provider, secret=SECRET), AsyncInMemoryStorage() + ) with pytest.raises(SCIMException) as excinfo: asyncio.run(handler.handle(scim_request)) @@ -121,7 +129,7 @@ def scheme(type): def service_announcing(*types): provider = load_default_provider() provider.config.authentication_schemes = [scheme(type) for type in types] - return ScimService(provider) + return ScimService(provider, secret=SECRET) @pytest.mark.parametrize( @@ -180,7 +188,7 @@ def www_authenticate(self, exception): def test_max_body_size(scim_provider, method, path, size): """Only a bulk request has a limit. A request that fails whatever its body gets no body.""" request = ScimRequest(method, "https://scim.example/v2", path) - assert ScimService(scim_provider).max_body_size(request) == size + assert ScimService(scim_provider, secret=SECRET).max_body_size(request) == size @pytest.mark.parametrize( @@ -191,4 +199,4 @@ def test_max_body_size_without_bulk(bulk): provider = load_default_provider() provider.config.bulk = bulk request = ScimRequest("POST", "https://scim.example/v2", "/Bulk") - assert ScimService(provider).max_body_size(request) == 0 + assert ScimService(provider, secret=SECRET).max_body_size(request) == 0 diff --git a/tests/test_storage.py b/tests/test_storage.py index 79533ff..6dfe1c8 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,19 +1,25 @@ import asyncio +import datetime +import json import warnings import pytest +from scim2_models import InvalidCursorException +from scim2_models import SearchRequest from scim2_server.memory import AsyncInMemoryStorage from scim2_server.memory import InMemoryStorage from scim2_server.storage import AsyncScimStorage from scim2_server.storage import ScimStorage +from scim2_server.storage import async_search_page +from scim2_server.storage import search_page class MinimalStorage(ScimStorage): def get(self, resource_type, resource_id, *, response_parameters=None): raise NotImplementedError - def search(self, resource_types, search_request): + def search(self, resource_types, search_request, *, position=None): raise NotImplementedError def create(self, resource_type, resource): @@ -36,7 +42,7 @@ class MinimalAsyncStorage(AsyncScimStorage): async def get(self, resource_type, resource_id, *, response_parameters=None): raise NotImplementedError - async def search(self, resource_types, search_request): + async def search(self, resource_types, search_request, *, position=None): raise NotImplementedError async def create(self, resource_type, resource): @@ -152,3 +158,180 @@ def test_a_deprecated_storage_still_serves_reads(self, wsgi): assert response.status_code == 200 assert response.json()["userName"] == "bjensen" + + +def users(storage, user_type, scim_provider, *names): + User = scim_provider.model_for(user_type) + return [storage.create(user_type, User(user_name=name)) for name in names] + + +def test_a_search_without_position_is_deprecated(): + """A storage whose search method does not accept position warns where it is defined.""" + with pytest.warns(DeprecationWarning, match="position") as record: + + class OldStorage(InMemoryStorage): + def search(self, resource_types, search_request): + raise NotImplementedError + + assert record[0].filename == __file__ + assert not OldStorage._search_takes_position + + +def test_an_async_search_without_position_is_deprecated(): + """An asynchronous storage whose search method does not accept position warns too.""" + with pytest.warns(DeprecationWarning, match="position"): + + class OldStorage(AsyncInMemoryStorage): + async def search(self, resource_types, search_request): + raise NotImplementedError + + assert not OldStorage._search_takes_position + + +def test_a_deprecated_search_pages_by_index(user_type, scim_provider): + """The server reads the number of resources and the page that a search without position returns.""" + with pytest.warns(DeprecationWarning): + + class OldStorage(InMemoryStorage): + def search(self, resource_types, search_request): + page = super().search(resource_types, search_request) + return page.total, page.resources + + storage = OldStorage() + created = users(storage, user_type, scim_provider, "alice", "bob", "carol") + + page = search_page( + storage, [user_type], SearchRequest(start_index=2, count=1), None + ) + + assert page.total == 3 + assert page.resources[0].id == sorted(user.id for user in created)[1] + assert (page.next, page.previous) == (None, None) + + +def test_a_deprecated_async_search_pages_by_index(user_type, scim_provider): + """An asynchronous search without position is read the same way.""" + with pytest.warns(DeprecationWarning): + + class OldStorage(AsyncInMemoryStorage): + async def search(self, resource_types, search_request): + page = await super().search(resource_types, search_request) + return page.total, page.resources + + storage = OldStorage() + users(storage.storage, user_type, scim_provider, "alice", "bob", "carol") + + page = asyncio.run( + async_search_page(storage, [user_type], SearchRequest(count=2), None) + ) + + assert page.total == 3 + assert len(page.resources) == 2 + + +def test_a_search_with_keyword_arguments_returning_a_tuple_pages_by_index( + user_type, scim_provider +): + """A storage that forwards any keyword argument to an older search still pages by index.""" + + class ForwardingStorage(InMemoryStorage): + def search(self, resource_types, search_request, **kwargs): + page = super().search(resource_types, search_request) + return page.total, page.resources + + storage = ForwardingStorage() + users(storage, user_type, scim_provider, "alice", "bob", "carol") + + page = search_page(storage, [user_type], SearchRequest(start_index=3), None) + + assert page.total == 3 + assert len(page.resources) == 1 + + +def test_an_async_search_with_keyword_arguments_returning_a_tuple_pages_by_index( + user_type, scim_provider +): + """An asynchronous storage that forwards any keyword argument to an older search still pages by index.""" + + class ForwardingStorage(AsyncInMemoryStorage): + async def search(self, resource_types, search_request, **kwargs): + page = await super().search(resource_types, search_request) + return page.total, page.resources + + storage = ForwardingStorage() + users(storage.storage, user_type, scim_provider, "alice", "bob", "carol") + + page = asyncio.run( + async_search_page(storage, [user_type], SearchRequest(start_index=3), None) + ) + + assert page.total == 3 + assert len(page.resources) == 1 + + +@pytest.mark.parametrize( + "position", + [ + 2, + "2", + [1], + {"d": "next", "k": None}, + {"d": "sideways", "k": None, "i": "1"}, + {"d": "next", "k": None, "i": 1}, + {"d": "next", "k": {"datetime": "yesterday"}, "i": "1"}, + {"d": "next", "k": {"date": "2026-10-09"}, "i": "1"}, + ], +) +def test_a_position_that_does_not_come_from_a_page_is_refused(user_type, position): + """A position that the storage did not give raises invalidCursor.""" + with pytest.raises(InvalidCursorException): + InMemoryStorage().search( + [user_type], SearchRequest(cursor="", count=2), position=position + ) + + +def test_the_resources_are_sorted_by_identifier_without_sort_by( + user_type, scim_provider +): + """Without sortBy, the resources come in the order of their identifiers, by index as by cursor.""" + storage = InMemoryStorage() + created = users(storage, user_type, scim_provider, "alice", "bob", "carol") + expected = sorted(user.id for user in created) + + by_index = storage.search([user_type], SearchRequest()) + by_cursor = storage.search([user_type], SearchRequest(cursor="")) + + assert [user.id for user in by_index.resources] == expected + assert [user.id for user in by_cursor.resources] == expected + + +def test_a_cursor_pages_by_date(user_type, scim_provider): + """The positions of a search sorted by a date go through JSON.""" + dates = iter( + datetime.datetime(2026, 10, day, tzinfo=datetime.UTC) for day in (3, 1, 2) + ) + storage = InMemoryStorage(clock=lambda: next(dates)) + users(storage, user_type, scim_provider, "carol", "alice", "bob") + search_request = SearchRequest(cursor="", count=1, sort_by="meta.created") + + pages = [storage.search([user_type], search_request)] + while pages[-1].next is not None: + position = json.loads(json.dumps(pages[-1].next)) + pages.append(storage.search([user_type], search_request, position=position)) + + assert [page.resources[0].user_name for page in pages] == ["alice", "bob", "carol"] + + +def test_a_cursor_refuses_a_sort_value_it_cannot_hold( + user_type, scim_provider, monkeypatch +): + """A sort value of a type that JSON cannot hold, from a custom model, is refused.""" + storage = InMemoryStorage() + users(storage, user_type, scim_provider, "alice", "bob") + value = object() + monkeypatch.setattr(SearchRequest, "sort_value", lambda self, resource: value) + + with pytest.raises(TypeError, match="cannot hold"): + storage.search( + [user_type], SearchRequest(cursor="", count=1, sort_by="userName") + ) diff --git a/uv.lock b/uv.lock index 5bba4ae..7700a66 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'emscripten'", @@ -152,6 +152,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.5.1" @@ -427,6 +525,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -892,6 +1046,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/cd/ce637f4b6cc6c583516dc16b539408dab593672f7c6fe7723a37caf378ff/prek-0.5.3-py3-none-win_arm64.whl", hash = "sha256:20d92aef53a5e237f4659ecc6669c0fbc98a1b83f0e2476530400a72d42ca390", size = 5512958, upload-time = "2026-09-13T08:38:36.123Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -1089,6 +1252,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1127,15 +1345,15 @@ wheels = [ [[package]] name = "scim2-models" -version = "0.12.1" +version = "0.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/76/1998e59747e445740a9ba3ac516a652167a869f247dce6be931d8725053d/scim2_models-0.12.1.tar.gz", hash = "sha256:13e2dc9aad991a05b24c2ae902c239ee6ed041ea8cd7551ed8f8ac4ac5011e96", size = 108082, upload-time = "2026-10-07T12:46:54.705Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/cba7330563df2f809db0e8cddd0ed0d64beddb42ff3af1eec6cacbf449c7/scim2_models-0.12.2.tar.gz", hash = "sha256:c8b1f7ae25af4874dba53edd2855ce126f7594d0dbe694a6e9bb32c369082234", size = 108565, upload-time = "2026-10-09T14:54:39.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/23/3e92c9ee55dba37009aa3934ffbe26da53de44038510268d293b683fd737/scim2_models-0.12.1-py3-none-any.whl", hash = "sha256:b127b5be4e915d73f3a0b8bfab48cd695516defe45bbb8c45683a9b5dc8da636", size = 130870, upload-time = "2026-10-07T12:46:53.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dc/3c3e0ea81420937a8502747c848379172608b0f9c5f77da9b796b19c3258/scim2_models-0.12.2-py3-none-any.whl", hash = "sha256:721b627f9ae50d24956103ba9fc2e852a1badbada8d25c0939bc8656881af62a", size = 131343, upload-time = "2026-10-09T14:54:38.229Z" }, ] [[package]] @@ -1147,6 +1365,9 @@ dependencies = [ ] [package.optional-dependencies] +cursor = [ + { name = "cryptography" }, +] precis = [ { name = "precis-i18n" }, ] @@ -1157,6 +1378,7 @@ testing = [ [package.dev-dependencies] dev = [ { name = "aiosqlite" }, + { name = "cryptography" }, { name = "httpx2" }, { name = "mypy" }, { name = "precis-i18n" }, @@ -1176,19 +1398,22 @@ doc = [ { name = "sphinx-argparse" }, { name = "sphinx-design" }, { name = "sphinx-issues" }, + { name = "sphinxcontrib-mermaid" }, ] [package.metadata] requires-dist = [ + { name = "cryptography", marker = "extra == 'cursor'", specifier = ">=50.0.1" }, { name = "precis-i18n", marker = "extra == 'precis'", specifier = ">=1.1.2" }, { name = "pytest", marker = "extra == 'testing'", specifier = ">=9.1.1" }, - { name = "scim2-models", specifier = ">=0.12.1" }, + { name = "scim2-models", specifier = ">=0.12.2" }, ] -provides-extras = ["testing", "precis"] +provides-extras = ["testing", "precis", "cursor"] [package.metadata.requires-dev] dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, + { name = "cryptography", specifier = ">=50.0.1" }, { name = "httpx2", specifier = ">=2.0.0" }, { name = "mypy", specifier = ">=2.3.1" }, { name = "precis-i18n", specifier = ">=1.1.2" }, @@ -1207,6 +1432,7 @@ doc = [ { name = "sphinx-argparse", specifier = ">=0.6.1" }, { name = "sphinx-design", specifier = ">=0.6.1" }, { name = "sphinx-issues", specifier = ">=5.0.0" }, + { name = "sphinxcontrib-mermaid", specifier = ">=2.1.1" }, ] [[package]] @@ -1386,6 +1612,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] +[[package]] +name = "sphinxcontrib-mermaid" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/87/06537dff20aa57df2d0f2afa87edff4afa85dec6f57f03f7acc24f83ff88/sphinxcontrib_mermaid-2.1.1.tar.gz", hash = "sha256:f8d7f1b2f0ad69b7ebdd5cbf532fb4ee107893b5373bc2d0e9634089c0b2cfee", size = 22353, upload-time = "2026-09-01T16:10:23.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/6a/5bf2222b8084c7a7bc3ec23d470c91e3e03e82e4ce0bbacaa61f456ce7c1/sphinxcontrib_mermaid-2.1.1-py3-none-any.whl", hash = "sha256:3b9ac1a8e0b5154dec3c0acf9b5c54dd9ba58540e416f20858c300814c25648f", size = 16368, upload-time = "2026-09-01T16:10:22.377Z" }, +] + [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0"