From 6495428fff6ed2a205d22b928c0480336491ef20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Tue, 29 Sep 2026 17:20:38 +0200 Subject: [PATCH 1/7] feat: project initialization --- .github/workflows/tests.yaml | 63 +- .gitignore | 45 +- .pre-commit-config.yaml | 17 +- LICENSE | 201 +++++ README.md | 81 +- doc/__init__.py | 0 doc/changelog.rst | 10 + doc/conf.py | 23 +- doc/conftest.py | 11 + doc/contributing.rst | 41 + doc/explanation/announced-features.rst | 46 ++ doc/explanation/extension-and-storage.rst | 69 ++ doc/explanation/index.rst | 14 + doc/explanation/limitations.rst | 37 + doc/explanation/versioning.rst | 42 + doc/how-to/accept-bulk-requests.rst | 77 ++ doc/how-to/announce-supported-features.rst | 69 ++ doc/how-to/change-resource-urls.rst | 43 + doc/how-to/check-conformance.rst | 40 + doc/how-to/connect-a-storage.rst | 111 +++ doc/how-to/index.rst | 21 + doc/how-to/protect-the-endpoints.rst | 59 ++ doc/how-to/serve-several-resource-types.rst | 57 ++ doc/how-to/support-conditional-requests.rst | 58 ++ .../tolerate-a-nonconformant-client.rst | 52 ++ doc/index.rst | 66 +- doc/reference.rst | 23 + doc/tutorial.rst | 300 +++++++ examples/minimal_server.py | 181 ++++ pyproject.toml | 66 +- src/scim2_flask/__init__.py | 5 + src/scim2_flask/extension.py | 779 ++++++++++++++++++ src/scim2_flask/storage.py | 92 +++ tests/conftest.py | 37 + tests/test_bulk.py | 238 ++++++ tests/test_enterprise_user.py | 76 ++ tests/test_etag.py | 116 +++ tests/test_extension.py | 366 ++++++++ tests/test_minimal_server.py | 107 +++ tests/test_root_search.py | 82 ++ tests/test_scim_tester.py | 39 + tests/test_unsupported_features.py | 118 +++ uv.lock | 522 +++++++++--- 43 files changed, 4334 insertions(+), 166 deletions(-) create mode 100644 LICENSE create mode 100644 doc/__init__.py create mode 100644 doc/changelog.rst create mode 100644 doc/conftest.py create mode 100644 doc/contributing.rst create mode 100644 doc/explanation/announced-features.rst create mode 100644 doc/explanation/extension-and-storage.rst create mode 100644 doc/explanation/index.rst create mode 100644 doc/explanation/limitations.rst create mode 100644 doc/explanation/versioning.rst create mode 100644 doc/how-to/accept-bulk-requests.rst create mode 100644 doc/how-to/announce-supported-features.rst create mode 100644 doc/how-to/change-resource-urls.rst create mode 100644 doc/how-to/check-conformance.rst create mode 100644 doc/how-to/connect-a-storage.rst create mode 100644 doc/how-to/index.rst create mode 100644 doc/how-to/protect-the-endpoints.rst create mode 100644 doc/how-to/serve-several-resource-types.rst create mode 100644 doc/how-to/support-conditional-requests.rst create mode 100644 doc/how-to/tolerate-a-nonconformant-client.rst create mode 100644 doc/reference.rst create mode 100644 doc/tutorial.rst create mode 100644 examples/minimal_server.py create mode 100644 src/scim2_flask/extension.py create mode 100644 src/scim2_flask/storage.py create mode 100644 tests/conftest.py create mode 100644 tests/test_bulk.py create mode 100644 tests/test_enterprise_user.py create mode 100644 tests/test_etag.py create mode 100644 tests/test_extension.py create mode 100644 tests/test_minimal_server.py create mode 100644 tests/test_root_search.py create mode 100644 tests/test_scim_tester.py create mode 100644 tests/test_unsupported_features.py diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a26ebeb..4052fe6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -18,6 +18,66 @@ permissions: contents: read jobs: + tests: + name: py${{ matrix.python }} unit tests + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + python: + - '3.14' + - '3.13' + - '3.12' + - '3.11' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.13" + python-version: ${{ matrix.python }} + enable-cache: true + - name: Run tests + run: uv run pytest --showlocals + + coverage: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.13" + python-version: "3.14" + enable-cache: true + - name: Run tests + run: uv run pytest --cov --cov-fail-under=100 + + minversions: + name: minimum dependency versions + runs-on: ubuntu-24.04 + env: + UV_RESOLUTION: lowest-direct + UV_LOCKED: 0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version: "0.12.13" + python-version: "3.11" + enable-cache: true + - name: Install minimum dependencies + run: uv sync + - name: Run tests + run: uv run pytest --showlocals + style: runs-on: ubuntu-24.04 steps: @@ -28,6 +88,7 @@ jobs: uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 with: version: "0.12.13" + python-version: "3.14" enable-cache: true - name: Run style checks run: uv run prek run --all-files --show-diff-on-failure @@ -50,7 +111,7 @@ jobs: all-checks: if: always() - needs: [style, doc] + needs: [tests, coverage, minversions, style, doc] runs-on: ubuntu-24.04 steps: - name: Fail if a needed job did not succeed diff --git a/.gitignore b/.gitignore index 457a843..1775d58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,35 @@ -.env -*.sqlite -*.pyc -*.mo -*.prof -.python_history -.tox +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ + +# Virtual environments +.venv/ +venv/ + +# uv +uv-cache/ + +# Testing +.pytest_cache/ +.cache/ +htmlcov/ .coverage .coverage.* -htmlcov -*.egg-info -build -dist -.vscode +coverage.xml + +# Type checking / linting +.mypy_cache/ +.ruff_cache/ +.dmypy.json + +# Editors / IDEs +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 16ff6c9..d4e0527 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,10 @@ --- +default_language_version: + python: python3.14 + repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: c60c980e561ed3e73101667fe8365c609d19a438 # frozen: v0.15.9 + rev: c60c980e561ed3e73101667fe8365c609d19a438 # frozen: v0.15.9 hooks: - id: ruff-check args: [--fix, --exit-non-zero-on-fix] @@ -9,7 +12,7 @@ repos: - id: ruff-format exclude: ^doc/guides/_examples/ - repo: https://github.com/pre-commit/pre-commit-hooks - rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: fix-byte-order-marker - id: trailing-whitespace @@ -18,7 +21,7 @@ repos: exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$" - id: check-toml - repo: https://github.com/pre-commit/mirrors-mypy - rev: 8e5c80792e2ec0c87804d8ef915bf35e2caea6da # frozen: v1.20.0 + rev: 8e5c80792e2ec0c87804d8ef915bf35e2caea6da # frozen: v1.20.0 hooks: - id: mypy exclude: ^(tests/|conftest\.py|doc/) @@ -26,11 +29,11 @@ repos: additional_dependencies: - pydantic[email]==2.13.5 - repo: https://github.com/codespell-project/codespell - rev: 2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a # frozen: v2.4.2 + rev: 2ccb47ff45ad361a21071a7eedda4c37e6ae8c5a # frozen: v2.4.2 hooks: - - id: codespell - args: [--write-changes] + - id: codespell + args: [--write-changes] - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1 + rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1 hooks: - id: zizmor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c153a2d..531b5f7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,80 @@ # scim2-flask -This repository is empty at the moment. -This is a placeholder for a library that will help implementing SCIM2 servers with Flask painlessly. -In the meantime, you can do it [manually with scim2-models](https://scim2-models.readthedocs.io/en/latest/integrations/flask.html) +A [Flask](https://flask.palletsprojects.com/) extension serving a SCIM 2.0 server, as +[RFC7643](https://datatracker.ietf.org/doc/html/rfc7643) and +[RFC7644](https://datatracker.ietf.org/doc/html/rfc7644) define it. It handles the protocol, and +relies on [scim2-models](https://scim2-models.readthedocs.io/) to validate and serialize the +payloads. The application provides the storage. + +## Features + +- Resource endpoints: `POST`, `GET`, `PUT`, `PATCH` and `DELETE`, for every resource type +- Search: `GET` and `POST /.search`, per resource type and at the server root +- Discovery endpoints: `/ServiceProviderConfig`, `/ResourceTypes` and `/Schemas` +- Bulk operations: `POST /Bulk`, with `failOnErrors`, `maxOperations` and `maxPayloadSize` +- Schema extensions, and several resource types sharing a schema +- Conditional requests with ETags: `If-Match` and `If-None-Match` +- SCIM error payloads for every failure + +## Example + +```python +from flask import Flask +from scim2_models import ScimProvider, User + +from scim2_flask import SCIM2, ScimStorage + + +class MyStorage(ScimStorage): + """Implement query, search, create, update and delete against your own storage.""" + + ... + + +app = Flask(__name__) +SCIM2(MyStorage(), ScimProvider(models=[User]), app=app) +``` + +## Installation + +```shell +pip install scim2-flask +``` + +## Documentation + +- [Tutorial](https://scim2-flask.readthedocs.io/en/latest/tutorial.html) builds a first SCIM + server, step by step. +- [How-to guides](https://scim2-flask.readthedocs.io/en/latest/how-to/index.html) cover focused + tasks, such as connecting your own storage. +- [Explanation](https://scim2-flask.readthedocs.io/en/latest/explanation/index.html) covers the + choices behind the extension, and what it leaves out. +- [Reference](https://scim2-flask.readthedocs.io/en/latest/reference.html) lists the public API. + +## What's SCIM anyway? + +SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol. +Provisioning is the action of managing a set of resources across different services, usually +users and groups. SCIM is often used between Identity Providers and applications, alongside +standards like OAuth2 and OpenID Connect. It allows users and groups creations, modifications and +deletions to be synchronized between applications. + +## Getting help + +Questions and bug reports go to the +[issue tracker](https://github.com/python-scim/scim2-flask/issues). + +## Contributing + +The [contribution page](https://scim2-flask.readthedocs.io/en/latest/contributing.html) +describes how to run the tests, the style checks and the documentation build. + +## License + +scim2-flask is released under the Apache-2.0 license. + +scim2-flask belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop), +with [scim2-models](https://github.com/python-scim/scim2-models), +[scim2-client](https://github.com/python-scim/scim2-client), +[scim2-tester](https://github.com/python-scim/scim2-tester) and +[scim2-cli](https://github.com/python-scim/scim2-cli). diff --git a/doc/__init__.py b/doc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/doc/changelog.rst b/doc/changelog.rst new file mode 100644 index 0000000..ca31e3e --- /dev/null +++ b/doc/changelog.rst @@ -0,0 +1,10 @@ +Changelog +========= + +[Unreleased] +------------ + +Added +^^^^^ +- :class:`~scim2_flask.SCIM2`, a Flask extension serving the resource, search, bulk and discovery + endpoints of :rfc:`RFC 7644 <7644>` over a :class:`~scim2_flask.ScimStorage`. diff --git a/doc/conf.py b/doc/conf.py index 3f94504..3d77739 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -4,9 +4,14 @@ # -- General configuration ------------------------------------------------ extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.doctest", "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.viewcode", "sphinx_issues", - "myst_parser", + "sphinx_paramlinks", + "sphinx_reredirects", ] templates_path = ["_templates"] @@ -15,14 +20,11 @@ year = datetime.datetime.now().strftime("%Y") copyright = f"{year}, Yaal Coop" author = "Yaal Coop" -source_suffix = { - ".rst": "restructuredtext", - ".md": "markdown", -} - +source_suffix = {".rst": "restructuredtext"} version = metadata.version("scim2-flask") language = "en" pygments_style = "sphinx" +todo_include_todos = False toctree_collapse = False intersphinx_mapping = { @@ -31,6 +33,9 @@ "scim2_client": ("https://scim2-client.readthedocs.io/en/latest/", None), "scim2_tester": ("https://scim2-tester.readthedocs.io/en/latest/", None), "scim2_cli": ("https://scim2-cli.readthedocs.io/en/latest/", None), + "pydantic": ("https://docs.pydantic.dev/latest/", None), + "flask": ("https://flask.palletsprojects.com/en/stable/", None), + "werkzeug": ("https://werkzeug.palletsprojects.com/en/stable/", None), } # -- Sibling projects ------------------------------------------------------ @@ -120,6 +125,12 @@ "source_docs_path": "/doc/", } +# -- Options for doctest ------------------------------------------- + +doctest_global_setup = """ +from scim2_flask import * +""" + # -- Options for sphinx-issues ------------------------------------- issues_github_path = "python-scim/scim2-flask" diff --git a/doc/conftest.py b/doc/conftest.py new file mode 100644 index 0000000..22b6e25 --- /dev/null +++ b/doc/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from examples.minimal_server import InMemoryStorage +from examples.minimal_server import create_provider + + +@pytest.fixture(autouse=True) +def tutorial_server(doctest_namespace): + """Expose the storage and the provider of the tutorial to the doctests of the pages.""" + doctest_namespace["InMemoryStorage"] = InMemoryStorage + doctest_namespace["create_provider"] = create_provider diff --git a/doc/contributing.rst b/doc/contributing.rst new file mode 100644 index 0000000..63d0e63 --- /dev/null +++ b/doc/contributing.rst @@ -0,0 +1,41 @@ +Contributing +============ + +Contributions are welcome! + +The repository is hosted at +`github.com/python-scim/scim2-flask `_. + +Discuss +------- + +A feature or a bugfix starts with a discussion on the +`bugtracker `_. + +Unit tests +---------- + +Run ``uv run pytest`` before submitting a patch. Everything must pass before a patch can be +merged. + +The test coverage threshold is 100%. Check it with +``uv run pytest --cov --cov-fail-under=100 --cov-report=html``. The report is written to +``htmlcov``. + +Code style +---------- + +The project uses `ruff `_ and other checks through +`prek `_. Run ``uv run prek run --all-files`` before submitting a +patch. Install the hooks with ``uv run prek install`` to run them before each commit. + +Documentation +------------- + +Build every page from scratch, with warnings treated as errors: + +.. code-block:: bash + + uv run --group doc sphinx-build -E -W --keep-going --builder html doc build/sphinx/html + +The generated documentation is located at ``build/sphinx/html``. diff --git a/doc/explanation/announced-features.rst b/doc/explanation/announced-features.rst new file mode 100644 index 0000000..0a844a3 --- /dev/null +++ b/doc/explanation/announced-features.rst @@ -0,0 +1,46 @@ +Announced features +================== + +A SCIM server describes the optional features it supports at ``/ServiceProviderConfig`` +(:rfc:`RFC7644 §4 <7644#section-4>`). Clients rely on that description, so it must match what the +server does. This page explains who keeps the two in line. + +Why the description comes from the application +---------------------------------------------- + +The extension cannot tell what a storage supports. A storage may filter but not sort, or cap its +pages at a size only its backend imposes. The application describes the service in the +:class:`~scim2_models.ScimProvider`, next to the storage it wrote. + +When the provider has no ``config``, the extension announces a default configuration, which +supports PATCH and no other feature. PATCH needs nothing from the storage beyond ``update``, so +every storage supports it. The other features stay off until the application announces them. + +Why the extension refuses some requests only +-------------------------------------------- + +The extension refuses the requests that rely on filtering, PATCH or bulk operations when the +service does not announce them, but it lets sorting and ETags through. +:doc:`../how-to/announce-supported-features` lists the answers. The line follows two questions: +who implements the feature, and whether the specifications say how to answer. + +Filtering, PATCH and bulk operations go through the extension. It parses the filter, applies the +PATCH operations, and runs the bulk operations one by one. It can therefore refuse them before the +storage is involved, and the specifications tell it how: + +- :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`: "Providers MUST decline to filter results if + the specified filter operation is not recognized and return an HTTP 400 error with a + "scimType" error of "invalidFilter""; +- :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, describes ``501 (Not Implemented)`` as + "Service provider does not support the request operation, e.g., PATCH."; +- :rfc:`RFC7644 §3.7.4 <7644#section-3.7.4>`, on the bulk limits: "If either limit is exceeded, + the service provider MUST return HTTP response code 413 (Payload Too Large)." + +Sorting and ETags depend on the storage. The storage orders the search results, and sets the +version of each resource. The specifications also leave the answer open: sorting and ETags are +OPTIONAL (:rfc:`RFC7644 §3.4.2.3 <7644#section-3.4.2.3>` and :rfc:`§3.14 <7644#section-3.14>`), +and neither section says how to answer a client that uses them anyway. The extension passes such +requests on, and the description stays accurate as long as the storage does not implement what +the service does not announce. + +The size of a search page depends on the storage too, which keeps it within ``maxResults``. diff --git a/doc/explanation/extension-and-storage.rst b/doc/explanation/extension-and-storage.rst new file mode 100644 index 0000000..06d456e --- /dev/null +++ b/doc/explanation/extension-and-storage.rst @@ -0,0 +1,69 @@ +The extension and the storage +============================= + +scim2-flask splits a SCIM server in two. The extension speaks the protocol, and the storage keeps +the resources. This page explains where the line runs, and why. + +What the extension does +----------------------- + +The extension handles everything the SCIM specifications define in the same way for every server: + +- the routes of every resource type, of the server root and of the discovery endpoints; +- the validation of each payload in the :class:`~scim2_models.Context` of its operation, so that + a creation request and a replacement request accept different attributes; +- the application of PUT and PATCH requests to the stored resource; +- the ``If-Match`` and ``If-None-Match`` checks; +- the orchestration of bulk requests; +- the conversion of every failure into a SCIM error payload. + +Most of this work relies on :doc:`scim2-models `. The extension connects it to +Flask. + +What the storage does +--------------------- + +The storage handles everything that depends on the backend: where the resources live, and how to +find them. That covers the identifiers and the dates, the uniqueness of attributes, and the +filtering, sorting and paging of searches. + +Uniqueness is a good example. Only the storage sees every resource, and only a database can check +a constraint atomically against concurrent writes. The same holds for searches: a SQL storage can +translate the filter into a query, where the extension could only walk every resource. + +Why ``update`` receives the whole resource +------------------------------------------ + +A PATCH request describes changes: add this value, remove that entry, replace the entries a +filter matches. Applying them correctly takes the SCIM path grammar, and the rules on immutable, +read-only and required attributes. scim2-models implements them, so the extension applies the +request to the stored resource, and hands the storage the result. + +The storage then needs a single ``update`` method, for PUT and PATCH alike, and it only persists a +state. A storage that received the operations would have to implement the PATCH rules for its own +backend, and each implementation could get them wrong in its own way. + +The price is a read before each write, and a full write where a partial one would do. + +Who fills ``meta`` +------------------ + +Every resource a SCIM server returns carries a ``meta`` attribute, which describes the resource +rather than the identity it represents (:rfc:`RFC7643 §3.1 <7643#section-3.1>`): its resource +type, its creation and modification dates, its URL and its version. None of these values comes +from the client. The server computes them all, and in scim2-flask, the extension and the storage +share that work. The storage sets ``meta.resourceType``, ``created`` and ``lastModified``, and the +extension sets ``meta.location``. Each value goes to the side that has the information it needs. + +The dates belong to the storage, since only the storage knows when it wrote a resource. + +``meta.resourceType`` also comes from the storage, because the model does not identify the +resource type. Two resource types may share a schema, such as users and administrators. A search +at the server root returns resources of every type in one list, and the extension reads +``meta.resourceType`` to know the endpoint of each one. + +``meta.location`` depends on the routes: the URL prefix, and the host the client reached. The +extension knows them, and the storage does not. An application that needs other URLs overrides +:meth:`~scim2_flask.SCIM2.resource_location`, in one place. + +``meta.version`` comes from the storage too, as :doc:`versioning` explains. diff --git a/doc/explanation/index.rst b/doc/explanation/index.rst new file mode 100644 index 0000000..922e634 --- /dev/null +++ b/doc/explanation/index.rst @@ -0,0 +1,14 @@ +Explanation +=========== + +These pages give the reasons behind the behaviour of scim2-flask: how it shares the work with the +storage, what it enforces, and what it leaves out. They are written to be read away from the +keyboard, rather than consulted while completing a task. + +.. toctree:: + :maxdepth: 1 + + extension-and-storage + announced-features + versioning + limitations diff --git a/doc/explanation/limitations.rst b/doc/explanation/limitations.rst new file mode 100644 index 0000000..9aec310 --- /dev/null +++ b/doc/explanation/limitations.rst @@ -0,0 +1,37 @@ +What the extension leaves out +============================= + +scim2-flask does not implement every part of the SCIM specifications. This page lists what it +leaves out, and why. + +Authentication and authorization +-------------------------------- + +Every SCIM endpoint the extension serves is open. Applications authenticate their clients in many +ways: OAuth 2.0 bearer tokens, HTTP basic authentication, client certificates. They also decide +differently which client may see or change which resource. No single scheme would fit them all, +so the application adds its own, as :doc:`../how-to/protect-the-endpoints` shows. + +For the same reason, the ``ServiceProviderConfig`` the extension announces by default lists no +authentication scheme. The application describes the one it implements. + +The ``/Me`` endpoint +-------------------- + +``/Me`` designates the resource of the authenticated client. The extension does not know who the +client is, so it cannot resolve that alias, and it answers ``501``. +:rfc:`RFC7644 §3.11 <7644#section-3.11>`: "A service provider that does NOT support this feature +SHOULD respond with HTTP status code 501 (Not Implemented)." + +``bulkId`` references +--------------------- + +In a bulk request, an operation can refer to a resource that an earlier operation of the same +request creates, with a temporary ``bulkId``. :rfc:`RFC7644 §3.7.2 <7644#section-3.7.2>`: "The +service provider MUST replace the string "bulkId:qwerty" with the permanent resource id once +created." + +The extension does not replace these references. Resolving them means ordering the operations by +their dependencies, and detecting circular references, which the +:doc:`scim2-models helpers ` the extension draws from do not do +either. A client that needs them can send the dependent operations in separate requests. diff --git a/doc/explanation/versioning.rst b/doc/explanation/versioning.rst new file mode 100644 index 0000000..c8bf7d2 --- /dev/null +++ b/doc/explanation/versioning.rst @@ -0,0 +1,42 @@ +Resource versions +================= + +Two clients that modify the same resource at the same time may overwrite each other's changes. +SCIM prevents it with ETags (:rfc:`RFC7644 §3.14 <7644#section-3.14>`): each resource carries a +version, and a client makes its modification conditional on the version it read. + +Who does what +------------- + +The storage computes the versions, and the extension compares them. + +A version must change whenever the resource changes, and only the storage writes resources. It +sets ``meta.version`` on each resource it creates or updates. + +The extension does the rest, the same way for every storage: + +- it sends the version in the ``ETag`` header; +- it answers ``304 Not Modified`` to a ``GET`` whose ``If-None-Match`` holds the current version; +- it answers ``412 Precondition Failed`` to a ``PUT``, ``PATCH`` or ``DELETE`` whose ``If-Match`` + holds another version; +- it compares the ``version`` of each bulk operation in the same way. + +A resource without version skips these checks. A storage without versioning therefore works +unchanged, and its clients simply cannot make their requests conditional. + +Why the example uses weak ETags +------------------------------- + +A strong ETag promises that two representations are identical byte for byte. A weak ETag only +promises that they are equivalent. :rfc:`RFC7644 §3.14 <7644#section-3.14>`: "Service providers +MAY support weak ETags as the preferred mechanism for performing conditional retrievals and +ensuring that clients do not inadvertently overwrite each other's changes, respectively." + +A SCIM server returns several representations of the same resource, depending on the +``attributes`` a client asks for, so a byte-for-byte promise would not hold. The example storage +hashes the content of the resource instead, with its dates, as the +:doc:`scim2-models helpers ` do. Every write therefore yields a +new version, even one that changes nothing else. + +A database storage may use a counter it increments on each write, or a timestamp. Any value works, +as long as it changes with the resource. diff --git a/doc/how-to/accept-bulk-requests.rst b/doc/how-to/accept-bulk-requests.rst new file mode 100644 index 0000000..47a1b65 --- /dev/null +++ b/doc/how-to/accept-bulk-requests.rst @@ -0,0 +1,77 @@ +Accept bulk requests +==================== + +Use this guide to let clients send many operations in a single request, with ``POST /Bulk`` +(:rfc:`RFC7644 §3.7 <7644#section-3.7>`). The extension runs each operation with the storage +methods it already uses for a single request, so the storage needs nothing more. + +Announce the bulk operations +---------------------------- + +Announce ``bulk`` in the :class:`~scim2_models.ServiceProviderConfig`, with the largest number of +operations and the largest payload, in bytes, the server accepts: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_models import ( + ... Bulk, ChangePassword, ETag, Filter, Patch, ScimProvider, ServiceProviderConfig, + ... Sort, User, + ... ) + >>> from scim2_flask import SCIM2 + >>> config = ServiceProviderConfig( + ... patch=Patch(supported=True), + ... bulk=Bulk(supported=True, max_operations=100, max_payload_size=1_048_576), + ... filter=Filter(supported=False), + ... change_password=ChangePassword(supported=False), + ... sort=Sort(supported=False), + ... etag=ETag(supported=True), + ... authentication_schemes=[], + ... ) + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), ScimProvider(models=[User], config=config), app=app) + +The extension answers ``413`` to a bulk request beyond either limit. + +Read the results +---------------- + +The extension runs the operations in the order the client sent them. Each operation gets its own +``status``, and a failed one also gets an error ``response``. A failure does not stop the next +operations: + +.. doctest:: + + >>> def create(bulk_id, user_name): + ... return { + ... "method": "POST", + ... "path": "/Users", + ... "bulkId": bulk_id, + ... "data": { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": user_name, + ... }, + ... } + >>> client = app.test_client() + >>> response = client.post( + ... "/scim/v2/Bulk", + ... json={ + ... "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], + ... "Operations": [create("a", "alice"), create("b", "alice"), create("c", "carol")], + ... }, + ... headers={"Content-Type": "application/scim+json"}, + ... ) + >>> [(operation["bulkId"], operation["status"]) for operation in response.json["Operations"]] + [('a', '201'), ('b', '409'), ('c', '201')] + >>> response.json["Operations"][1]["response"]["detail"] + "userName 'alice' is already taken" + +A client that sets ``failOnErrors`` stops the request after that many failures. The operations +that already succeeded stay applied: the extension does not undo them. + +Operations on existing resources, with ``PUT``, ``PATCH`` or ``DELETE``, can carry a ``version``. +When the resource has one, an operation whose ``version`` differs gets the status ``412``, as +:doc:`support-conditional-requests` describes for single requests. + +The extension does not resolve ``bulkId`` references between operations. +:doc:`../explanation/limitations` explains why. diff --git a/doc/how-to/announce-supported-features.rst b/doc/how-to/announce-supported-features.rst new file mode 100644 index 0000000..a645de6 --- /dev/null +++ b/doc/how-to/announce-supported-features.rst @@ -0,0 +1,69 @@ +Announce the supported features +=============================== + +Use this guide to tell clients which optional SCIM features the server supports, such as +filtering or bulk operations. Clients read them at ``/ServiceProviderConfig`` +(:rfc:`RFC7644 §4 <7644#section-4>`). + +Describe the features +--------------------- + +Give the :class:`~scim2_models.ScimProvider` a :class:`~scim2_models.ServiceProviderConfig`. +Announce only what the storage actually does: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_models import ( + ... Bulk, ChangePassword, ETag, Filter, Patch, ScimProvider, ServiceProviderConfig, + ... Sort, User, + ... ) + >>> from scim2_flask import SCIM2 + >>> config = ServiceProviderConfig( + ... patch=Patch(supported=True), + ... bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + ... filter=Filter(supported=True, max_results=50), + ... change_password=ChangePassword(supported=False), + ... sort=Sort(supported=True), + ... etag=ETag(supported=True), + ... authentication_schemes=[], + ... ) + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), ScimProvider(models=[User], config=config), app=app) + +Give every attribute, even for the features the server does not support: the extension announces +the configuration as it is. :rfc:`RFC7643 §5 <7643#section-5>` requires all of them, and +recommends ``authenticationSchemes``. + +When the provider has no ``config``, the extension announces a default configuration instead: it +supports PATCH, and no other feature. + +Know what the extension enforces +-------------------------------- + +The extension refuses the requests that go beyond what the service announces: + +- a ``filter`` answers ``400 invalidFilter``, when ``filter`` is not supported; +- a ``PATCH``, or a PATCH operation in a bulk request, answers ``501``, when ``patch`` is not + supported; +- a ``POST /Bulk`` answers ``501``, when ``bulk`` is not supported; +- a bulk request beyond ``maxOperations`` or ``maxPayloadSize`` answers ``413``. + +.. doctest:: + + >>> client = app.test_client() + >>> response = client.post("/scim/v2/Bulk", json={}, headers={"Content-Type": "application/scim+json"}) + >>> response.status_code + 501 + +It passes the other requests to the storage, even when they rely on a feature announced as +unsupported: + +- without ``sort``, the storage still receives ``sortBy`` and ``sortOrder``. Leave them unused; +- without ``etag``, the extension still sends the ``meta.version`` the storage sets, and checks + ``If-Match`` and ``If-None-Match`` against it. Leave ``meta.version`` unset. + +The storage also enforces ``maxResults``: keep every search page within it, as +:doc:`connect-a-storage` describes. + +:doc:`../explanation/announced-features` explains why the extension refuses some requests only. diff --git a/doc/how-to/change-resource-urls.rst b/doc/how-to/change-resource-urls.rst new file mode 100644 index 0000000..b661852 --- /dev/null +++ b/doc/how-to/change-resource-urls.rst @@ -0,0 +1,43 @@ +Change the resource URLs +======================== + +Use this guide when the SCIM endpoints must live under another path, or when clients reach the +server through a URL the application does not see, such as behind a reverse proxy. + +Change the prefix +----------------- + +The extension serves the endpoints under ``/scim/v2`` by default. Pass another ``url_prefix``: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_flask import SCIM2 + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), create_provider(), app=app, url_prefix="/api/scim") + >>> app.test_client().get("/api/scim/Users").status_code + 200 + +Change the location of the resources +------------------------------------ + +The extension builds every ``meta.location``, ``Location`` header and bulk ``location`` with +:meth:`~scim2_flask.SCIM2.resource_location`. Override it to return the URL clients use: + +.. doctest:: + + >>> class PublicSCIM2(SCIM2): + ... def resource_location(self, resource_type, resource_id): + ... return f"https://scim.example.org/v2{resource_type.endpoint}/{resource_id}" + >>> app = Flask(__name__) + >>> scim2 = PublicSCIM2(InMemoryStorage(), create_provider(), app=app) + >>> response = app.test_client().post( + ... "/scim/v2/Users", + ... json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen"}, + ... headers={"Content-Type": "application/scim+json"}, + ... ) + >>> response.json["meta"]["location"] + 'https://scim.example.org/v2/Users/...' + +Behind a reverse proxy, Werkzeug's :class:`~werkzeug.middleware.proxy_fix.ProxyFix` is an +alternative: it makes the generated URLs use the host and scheme the proxy received. diff --git a/doc/how-to/check-conformance.rst b/doc/how-to/check-conformance.rst new file mode 100644 index 0000000..06ed5e1 --- /dev/null +++ b/doc/how-to/check-conformance.rst @@ -0,0 +1,40 @@ +Check the server conformance +============================ + +Use this guide to check that your server follows :rfc:`RFC 7643 <7643>` and +:rfc:`RFC 7644 <7644>`, your storage included, for instance in your test suite. The scim2-flask +test suite checks the extension with the in-memory storage of the :doc:`../tutorial` only, while +much of the conformance depends on the storage, as :doc:`connect-a-storage` shows. + +:doc:`scim2-tester ` sends the server the requests a SCIM client would, and +reports the answers that break the specifications. + +Install scim2-tester: + +.. code-block:: shell + + pip install scim2-tester + +Run the checks +-------------- + +Wrap the application in a scim2-client test client, and pass it to +:func:`~scim2_tester.check_server`: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_client.engines.werkzeug import TestSCIMClient + >>> from scim2_tester import check_server + >>> from werkzeug.test import Client + >>> from scim2_flask import SCIM2 + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), create_provider(), app=app) + >>> client = TestSCIMClient(Client(app), scim_prefix="/scim/v2", provider=scim2.provider) + >>> results = check_server(client, include_tags={"discovery", "crud:create"}) + >>> sorted({result.status.name for result in results}) + ['SKIPPED', 'SUCCESS'] + +The server conforms when every check succeeds, or is skipped because the server does not +announce the feature it checks. Each result also has a ``title`` naming the check, and a +``reason`` when it fails. Leave ``include_tags`` out to run every check. diff --git a/doc/how-to/connect-a-storage.rst b/doc/how-to/connect-a-storage.rst new file mode 100644 index 0000000..30abf52 --- /dev/null +++ b/doc/how-to/connect-a-storage.rst @@ -0,0 +1,111 @@ +Connect your own storage +======================== + +Use this guide when the resources live in your own backend, such as a SQL database or an LDAP +directory. The extension reads and writes them through a :class:`~scim2_flask.ScimStorage` +subclass, and handles the rest of the protocol. + +The examples below come from the ``InMemoryStorage`` of the :doc:`../tutorial`, which keeps the +resources in a dictionary. + +Subclass the storage +-------------------- + +Subclass :class:`~scim2_flask.ScimStorage`, and implement its five methods: ``query``, +``search``, ``create``, ``update`` and ``delete``. Pass an instance to the extension: + +.. code-block:: python + + from scim2_flask import SCIM2, ScimStorage + + + class MyStorage(ScimStorage): ... + + + SCIM2(MyStorage(), provider, app=app) + +Every method receives the :class:`~scim2_models.ResourceType` it applies to. Keep the resources +of each resource type apart, by the resource type ``name``: two resource types may share a +schema, as :doc:`serve-several-resource-types` shows. + +Every resource a method returns must: + +- be an instance of the model the provider composes for its resource type, such as + ``User[EnterpriseUser]``. :meth:`ScimProvider.model_for ` + returns it; +- carry the resource type ``name`` in ``meta.resourceType``. The extension raises + :exc:`ValueError` otherwise. + +The extension sets ``meta.location`` itself. + +Read a resource +--------------- + +``query`` returns the resource of a resource type with a given ``id``. Raise +:class:`~scim2_flask.ResourceNotFoundError` when there is none: the client receives a +``404``. + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.query + +Create a resource +----------------- + +``create`` receives a resource the client sent, without ``id``. Give it an ``id`` and a +``meta`` holding ``resourceType``, ``created`` and ``lastModified``, store it, and return it: + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.create + +Raise a :class:`~scim2_models.SCIMException` to refuse the resource. The client receives the +matching SCIM error, such as ``409 uniqueness`` for a +:class:`~scim2_models.UniquenessException`. + +Update a resource +----------------- + +``update`` receives the complete state the resource must have, with its ``id``. The extension +already applied the PUT or PATCH request to the stored resource. Store the new state, keep +``meta.created``, update ``meta.lastModified``, and return it: + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.update + +Raise :class:`~scim2_flask.ResourceNotFoundError` when no resource has that ``id``. + +Delete a resource +----------------- + +``delete`` removes the resource of a resource type with a given ``id``. Raise +:class:`~scim2_flask.ResourceNotFoundError` when there is none: + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.delete + +Search resources +---------------- + +``search`` receives a list of resource types, and a :class:`~scim2_models.SearchRequest`. The +list holds one resource type for a search on its endpoint, such as ``/Users``, and all of them +for a search at the server root. Filter, sort and page the resources of those types as a single +collection, and return the total number of matches along with the page: + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.search + +The :class:`~scim2_models.SearchRequest` provides what the query needs: + +- :meth:`filter.match ` tells whether a resource matches the + ``filter``; +- :meth:`~scim2_models.SearchRequest.sort` orders resources by ``sortBy`` and ``sortOrder``; +- ``start_index_0`` and ``count`` delimit the page. + +Keep a page within the ``maxResults`` the provider announces, and raise a +:class:`~scim2_models.TooManyException` for a query the storage refuses to process. To translate +the filter into a database query rather than walking the resources in Python, see the +:doc:`filter transpiler ` of scim2-models. diff --git a/doc/how-to/index.rst b/doc/how-to/index.rst new file mode 100644 index 0000000..027f000 --- /dev/null +++ b/doc/how-to/index.rst @@ -0,0 +1,21 @@ +How-to guides +============= + +These guides apply scim2-flask to a task whose outcome is already known. Each one starts and ends +where an application resumes its own work, and assumes the :doc:`../tutorial`. + +The examples reuse ``InMemoryStorage`` and ``create_provider`` from the tutorial. Replace them +with your own storage and provider. + +.. toctree:: + :maxdepth: 1 + + connect-a-storage + announce-supported-features + support-conditional-requests + accept-bulk-requests + serve-several-resource-types + protect-the-endpoints + tolerate-a-nonconformant-client + change-resource-urls + check-conformance diff --git a/doc/how-to/protect-the-endpoints.rst b/doc/how-to/protect-the-endpoints.rst new file mode 100644 index 0000000..c6eae7b --- /dev/null +++ b/doc/how-to/protect-the-endpoints.rst @@ -0,0 +1,59 @@ +Protect the endpoints +===================== + +Use this guide to restrict the SCIM endpoints to authenticated clients. The extension leaves +every endpoint open, and lets the application check the credentials. + +Check the credentials +--------------------- + +Subclass :class:`~scim2_flask.SCIM2`, and add a ``before_request`` hook to the blueprint it +creates. The hook runs before every SCIM request, and only before them. Raise a Werkzeug +:class:`~werkzeug.exceptions.Unauthorized` to refuse a request, and name the scheme the server +expects in its ``www_authenticate``: + +.. doctest:: + + >>> from flask import Flask, request + >>> from werkzeug.datastructures import WWWAuthenticate + >>> from werkzeug.exceptions import Unauthorized + >>> from scim2_flask import SCIM2 + >>> def check_token(): + ... if request.headers.get("Authorization") != "Bearer secret": + ... raise Unauthorized( + ... "Missing or invalid token", www_authenticate=WWWAuthenticate("Bearer") + ... ) + >>> class ProtectedSCIM2(SCIM2): + ... def create_blueprint(self): + ... blueprint = super().create_blueprint() + ... blueprint.before_request(check_token) + ... return blueprint + >>> app = Flask(__name__) + >>> scim2 = ProtectedSCIM2(InMemoryStorage(), create_provider(), app=app) + +Replace ``check_token`` with the check your application needs, such as the validation of an +OAuth 2.0 bearer token. + +The client receives a SCIM error, and the ``WWW-Authenticate`` header naming the scheme. +:rfc:`RFC7644 §2 <7644#section-2>`: "As per Section 4.1 of [RFC7235], a SCIM service provider +SHALL indicate supported HTTP authentication schemes via the "WWW-Authenticate" header." + +.. doctest:: + + >>> client = app.test_client() + >>> response = client.get("/scim/v2/Users") + >>> response.status_code + 401 + >>> response.headers["WWW-Authenticate"] + 'Bearer' + >>> response.json["detail"] + 'Missing or invalid token' + >>> client.get("/scim/v2/Users", headers={"Authorization": "Bearer secret"}).status_code + 200 + +Announce the scheme +------------------- + +Describe the authentication scheme in the ``authentication_schemes`` of the +:class:`~scim2_models.ServiceProviderConfig`, so that clients know which credentials to send +(:rfc:`RFC7643 §5 <7643#section-5>`). See :doc:`announce-supported-features`. diff --git a/doc/how-to/serve-several-resource-types.rst b/doc/how-to/serve-several-resource-types.rst new file mode 100644 index 0000000..29154e8 --- /dev/null +++ b/doc/how-to/serve-several-resource-types.rst @@ -0,0 +1,57 @@ +Serve several resource types +============================ + +Use this guide when the server exposes more than users and groups, or when two endpoints serve +resources described by the same schema, such as users and administrators. + +Declare the resource types +-------------------------- + +Each :class:`~scim2_models.ResourceType` of the :class:`~scim2_models.ScimProvider` gets its own +endpoints. Declare a resource type for every collection, and give each one a distinct ``name`` +and ``endpoint``. Here, ``/Admins`` serves resources described by the +:class:`~scim2_models.User` schema: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_models import ResourceType, ScimProvider, User + >>> from scim2_flask import SCIM2 + >>> admins = ResourceType( + ... id="Admin", + ... name="Admin", + ... endpoint="/Admins", + ... schema_="urn:ietf:params:scim:schemas:core:2.0:User", + ... ) + >>> provider = ScimProvider( + ... models=[User], + ... resource_types=[ResourceType.from_resource(User), admins], + ... ) + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), provider, app=app) + +To serve a resource with extensions, or a custom resource, see +:doc:`scim2-models `. + +Keep the collections apart +-------------------------- + +The storage receives the :class:`~scim2_models.ResourceType` of each request. Store the +resources by its ``name``, not by their schema: an administrator must not appear in ``/Users``. + +.. doctest:: + + >>> client = app.test_client() + >>> response = client.post( + ... "/scim/v2/Admins", + ... json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "root"}, + ... headers={"Content-Type": "application/scim+json"}, + ... ) + >>> response.json["meta"]["resourceType"] + 'Admin' + >>> client.get("/scim/v2/Users").json["totalResults"] + 0 + >>> client.get("/scim/v2/Admins").json["totalResults"] + 1 + +A search at the server root, ``POST /.search``, covers every resource type. diff --git a/doc/how-to/support-conditional-requests.rst b/doc/how-to/support-conditional-requests.rst new file mode 100644 index 0000000..85aa9dd --- /dev/null +++ b/doc/how-to/support-conditional-requests.rst @@ -0,0 +1,58 @@ +Support conditional requests +============================ + +Use this guide to let clients detect concurrent changes with ETags +(:rfc:`RFC7644 §3.14 <7644#section-3.14>`). A client then reads a resource only when it changed, +and modifies it only when nobody else did in the meantime. + +Give each resource a version +---------------------------- + +Set ``meta.version`` on every resource the storage creates or updates. The value must change +whenever the resource changes. The tutorial storage computes a weak ETag from the content: + +.. literalinclude:: ../../examples/minimal_server.py + :language: python + :pyobject: make_etag + +Then announce the feature with ``etag=ETag(supported=True)`` in the +:class:`~scim2_models.ServiceProviderConfig`, as :doc:`announce-supported-features` shows. +Without it, the extension ignores the versions. + +Check the result +---------------- + +The extension then sends the version in the ``ETag`` header of every resource response: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_flask import SCIM2 + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), create_provider(), app=app) + >>> client = app.test_client() + >>> headers = {"Content-Type": "application/scim+json"} + >>> response = client.post( + ... "/scim/v2/Users", + ... json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen"}, + ... headers=headers, + ... ) + >>> etag = response.headers["ETag"] + >>> location = response.json["meta"]["location"] + +It answers ``304`` to a ``GET`` whose ``If-None-Match`` holds the current version: + +.. doctest:: + + >>> client.get(location, headers={"If-None-Match": etag}).status_code + 304 + +It answers ``412`` to a ``PUT``, ``PATCH`` or ``DELETE`` whose ``If-Match`` holds another +version: + +.. doctest:: + + >>> client.delete(location, headers={"If-Match": 'W/"outdated"'}).status_code + 412 + +In a bulk request, the extension compares the ``version`` of each operation the same way. diff --git a/doc/how-to/tolerate-a-nonconformant-client.rst b/doc/how-to/tolerate-a-nonconformant-client.rst new file mode 100644 index 0000000..3ee77d6 --- /dev/null +++ b/doc/how-to/tolerate-a-nonconformant-client.rst @@ -0,0 +1,52 @@ +Tolerate a nonconformant client +=============================== + +Use this guide when a real SCIM client sends payloads the server refuses, such as attributes no +schema declares. The extension reads every payload under the :class:`~scim2_models.ScimPolicy` of +the :class:`~scim2_models.ScimProvider`, and the policy says how much to accept beyond what +:rfc:`RFC7643 <7643>` and :rfc:`RFC7644 <7644>` describe. + +Set the policy +-------------- + +By default, the server refuses a payload carrying an unknown attribute: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_models import ScimPolicy, ScimProvider, User + >>> from scim2_flask import SCIM2 + >>> payload = { + ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + ... "userName": "bjensen", + ... "vendorAttribute": "some value", + ... } + >>> headers = {"Content-Type": "application/scim+json"} + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), ScimProvider(models=[User]), app=app) + >>> app.test_client().post("/scim/v2/Users", json=payload, headers=headers).status_code + 400 + +Give the provider a policy that ignores unknown attributes. The server then stores the rest of the +payload: + +.. doctest:: + + >>> policy = ScimPolicy(unknown=ScimPolicy.Unknown.ignore) + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), ScimProvider(models=[User], policy=policy), app=app) + >>> response = app.test_client().post("/scim/v2/Users", json=payload, headers=headers) + >>> response.status_code + 201 + >>> "vendorAttribute" in response.json + False + +The policy applies to every request the extension serves, bulk operations included. + +Choose the settings +------------------- + +A :class:`~scim2_models.ScimPolicy` has other settings, for the PATCH requests some clients send. +Each one defaults to the strict reading of the specifications. The scim2-models guide +:doc:`scim2_models:how-to/tolerate-a-nonconformant-peer` describes them, and the clients they +help. diff --git a/doc/index.rst b/doc/index.rst index 2668f61..af596b0 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -1,2 +1,64 @@ -.. include:: ../README.md - :parser: myst_parser.parsers.docutils_ +scim2-flask +=========== + +scim2-flask is a `Flask `_ extension serving a SCIM +2.0 server, as :rfc:`RFC 7643 <7643>` and :rfc:`RFC 7644 <7644>` define it. It exposes the +resource, search, bulk and discovery endpoints, and relies on +:doc:`scim2-models ` to validate and serialize the payloads. + +An application gives the extension a storage, here the ``InMemoryStorage`` of the +:doc:`tutorial`, and the models it serves. Clients can then create users: + +.. doctest:: + + >>> from flask import Flask + >>> from scim2_models import ScimProvider, User + >>> from scim2_flask import SCIM2 + >>> app = Flask(__name__) + >>> scim2 = SCIM2(InMemoryStorage(), ScimProvider(models=[User]), app=app) + >>> response = app.test_client().post( + ... "/scim/v2/Users", + ... json={"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen"}, + ... ) + >>> response.status_code + 201 + >>> response.json["userName"] + 'bjensen' + +The extension handles the SCIM protocol. The application must provide the rest: + +- a :class:`~scim2_flask.ScimStorage` subclass, which reads and writes the resources; +- a :class:`~scim2_models.ScimProvider`, which declares the resource types the server serves and + the features it supports; +- authentication and authorization, since the extension leaves every endpoint open. + +This documentation addresses Python developers who build a SCIM server with Flask. It assumes +familiarity with Flask applications, and with the models of :doc:`scim2-models `. + +.. code-block:: shell + + pip install scim2-flask + +Choose a path +------------- + +:doc:`Tutorial ` builds a first SCIM server, step by step. + +:doc:`How-to guides ` show how to complete a specific task, such as connecting your +own storage. + +:doc:`Explanation ` gives the reasons behind the behaviour of the extension, +and what it leaves out. + +:doc:`Reference ` lists the complete public API. + +.. toctree:: + :maxdepth: 2 + :hidden: + + Tutorial + How-to guides + Explanation + Reference + Contributing + Changelog diff --git a/doc/reference.rst b/doc/reference.rst new file mode 100644 index 0000000..28511b5 --- /dev/null +++ b/doc/reference.rst @@ -0,0 +1,23 @@ +Reference +========= + +This reference describes the public scim2-flask API. + +Extension +--------- + +The Flask extension serving the SCIM endpoints. + +.. autoclass:: scim2_flask.SCIM2 + :members: + +Storage +------- + +The contract a storage backend implements, and the exception it raises. + +.. autoclass:: scim2_flask.ScimStorage + :members: + +.. autoclass:: scim2_flask.ResourceNotFoundError + :members: diff --git a/doc/tutorial.rst b/doc/tutorial.rst new file mode 100644 index 0000000..c7c1dd9 --- /dev/null +++ b/doc/tutorial.rst @@ -0,0 +1,300 @@ +Tutorial +======== + +In this tutorial, we will build a SCIM server that stores users and groups in memory. We will +then create and read resources with ``curl``, as a SCIM client would. + +We need Python 3.11 or later, and ``curl``. + +Install scim2-flask +------------------- + +First, install scim2-flask. It brings Flask and scim2-models along: + +.. code-block:: shell + + pip install scim2-flask + +Now create an empty file named ``server.py``. We will fill it step by step. + +Import the building blocks +-------------------------- + +Start ``server.py`` with the imports, and with the limits the server will announce to its +clients: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :start-at: import hashlib + :end-before: def make_etag + +Describe the service +-------------------- + +A :class:`~scim2_models.ScimProvider` describes what the server serves. Ours serves users, with +the enterprise extension, and groups. It also lists the features the server supports, such as +filtering and sorting: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: create_provider + +Store the resources +------------------- + +The extension handles the SCIM protocol, but it does not store anything. A +:class:`~scim2_flask.ScimStorage` subclass does. + +Our storage gives each resource a version when it creates or updates it. Clients use that version +to detect concurrent changes. Add the function computing it: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: make_etag + +Then add the storage class. It keeps the resources in a dictionary, one per resource type: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage + :end-before: def search( + +Add a ``search`` method to the class. It filters, sorts and pages the resources, as the client +asks: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.search + +Add the ``create`` and ``update`` methods to the class. They also check that no two users +share a ``userName``: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.create + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.update + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage._check_user_name_unique + +Finally, add the ``delete`` method: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: InMemoryStorage.delete + +Create the application +---------------------- + +The :class:`~scim2_flask.SCIM2` extension connects the storage and the provider to a Flask +application. Add the application factory, and the lines starting the server: + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :pyobject: create_app + +.. literalinclude:: ../examples/minimal_server.py + :language: python + :start-at: if __name__ == "__main__": + +Start the server: + +.. code-block:: shell + + python server.py + +The output should look something like this: + +.. code-block:: text + + * Serving Flask app 'server' + * Debug mode: on + * Running on http://127.0.0.1:5000 + +Leave the server running, and open a second terminal for the next steps. + +Create a user +------------- + +Send a user to the ``/Users`` endpoint: + +.. code-block:: shell + + curl -X POST http://localhost:5000/scim/v2/Users \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' + +The server answers with the user it stored: + +.. code-block:: json + + { + "id": "fc4cd4d3-3667-4872-9d98-11ca9ad19303", + "meta": { + "created": "2026-09-29T12:53:13.116241Z", + "lastModified": "2026-09-29T12:53:13.116241Z", + "location": "http://localhost:5000/scim/v2/Users/fc4cd4d3-3667-4872-9d98-11ca9ad19303", + "resourceType": "User", + "version": "W/\"a7142e634095740c\"" + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ], + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "42" + }, + "userName": "bjensen" + } + +Notice that the user now has an ``id`` and a ``meta`` attribute. The storage set the ``id`` and +the dates, and the extension added the ``location``. Your ``id``, dates and ``version`` differ +from the ones above. + +Read the user back +------------------ + +Copy the ``location`` from the output, and request it: + +.. code-block:: shell + + curl http://localhost:5000/scim/v2/Users/fc4cd4d3-3667-4872-9d98-11ca9ad19303 + +The server answers with the same user: + +.. code-block:: json + + { + "id": "fc4cd4d3-3667-4872-9d98-11ca9ad19303", + "meta": { + "created": "2026-09-29T12:53:13.116241Z", + "lastModified": "2026-09-29T12:53:13.116241Z", + "location": "http://localhost:5000/scim/v2/Users/fc4cd4d3-3667-4872-9d98-11ca9ad19303", + "resourceType": "User", + "version": "W/\"a7142e634095740c\"" + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" + ], + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "42" + }, + "userName": "bjensen" + } + +Create the user again +--------------------- + +Send the same user a second time: + +.. code-block:: shell + + curl -X POST http://localhost:5000/scim/v2/Users \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' + +The storage refuses it, since another user already has this ``userName``: + +.. code-block:: json + + { + "detail": "userName 'bjensen' is already taken", + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "scimType": "uniqueness", + "status": "409" + } + +Create a group +-------------- + +Send a group to the ``/Groups`` endpoint: + +.. code-block:: shell + + curl -X POST http://localhost:5000/scim/v2/Groups \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Engineers"}' + +The server answers with the group it stored: + +.. code-block:: json + + { + "displayName": "Engineers", + "id": "5914328f-2ec1-4ddf-89a0-37a1fdba328f", + "meta": { + "created": "2026-09-29T12:53:13.129902Z", + "lastModified": "2026-09-29T12:53:13.129902Z", + "location": "http://localhost:5000/scim/v2/Groups/5914328f-2ec1-4ddf-89a0-37a1fdba328f", + "resourceType": "Group", + "version": "W/\"826f4129f68ddc27\"" + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ] + } + +List the groups +--------------- + +Request the ``/Groups`` endpoint: + +.. code-block:: shell + + curl http://localhost:5000/scim/v2/Groups + +The server answers with a list holding the group we created: + +.. code-block:: json + + { + "Resources": [ + { + "displayName": "Engineers", + "id": "5914328f-2ec1-4ddf-89a0-37a1fdba328f", + "meta": { + "created": "2026-09-29T12:53:13.129902Z", + "lastModified": "2026-09-29T12:53:13.129902Z", + "location": "http://localhost:5000/scim/v2/Groups/5914328f-2ec1-4ddf-89a0-37a1fdba328f", + "resourceType": "Group", + "version": "W/\"826f4129f68ddc27\"" + }, + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:Group" + ] + } + ], + "itemsPerPage": 1, + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:ListResponse" + ], + "startIndex": 1, + "totalResults": 1 + } + +Notice that ``/Groups`` lists only the group: each resource type has its own collection. + +What we built +------------- + +We built a SCIM server that creates, reads and lists users and groups. The same server also +answers PUT, PATCH, DELETE, search and bulk requests, and describes itself at +``/ServiceProviderConfig``, ``/ResourceTypes`` and ``/Schemas``. + +The complete ``server.py`` is available as +`examples/minimal_server.py `_. +The :doc:`reference` describes :class:`~scim2_flask.SCIM2` and +:class:`~scim2_flask.ScimStorage` in full. diff --git a/examples/minimal_server.py b/examples/minimal_server.py new file mode 100644 index 0000000..5bce508 --- /dev/null +++ b/examples/minimal_server.py @@ -0,0 +1,181 @@ +"""Minimal SCIM server built with scim2-flask. + +Run it with: + + uv run python examples/minimal_server.py +""" + +import hashlib +import json +from collections import defaultdict +from datetime import UTC +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from flask import Flask +from scim2_models import Bulk +from scim2_models import ChangePassword +from scim2_models import EnterpriseUser +from scim2_models import ETag +from scim2_models import Filter +from scim2_models import Group +from scim2_models import Meta +from scim2_models import Patch +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig +from scim2_models import Sort +from scim2_models import UniquenessException +from scim2_models import User + +from scim2_flask import SCIM2 +from scim2_flask import ResourceNotFoundError +from scim2_flask import ScimStorage + +MAX_RESULTS = 50 +MAX_BULK_OPERATIONS = 100 +MAX_BULK_PAYLOAD_SIZE = 1_048_576 + + +def make_etag(resource: Resource[Any]) -> str: + """Compute a weak ETag from a resource's content. + + :rfc:`RFC7644 §3.14 <7644#section-3.14>`: "Service providers MAY support + weak ETags as the preferred mechanism for performing conditional + retrievals and ensuring that clients do not inadvertently overwrite each + other's changes, respectively." + """ + content = resource.model_dump( + mode="json", exclude={"meta": {"version", "location"}}, scim_ctx=None + ) + digest = hashlib.sha256(json.dumps(content, sort_keys=True).encode()).hexdigest() + return f'W/"{digest[:16]}"' + + +class InMemoryStorage(ScimStorage): + """A :class:`ScimStorage` storing resources in a plain dict per resource type name. + + A real deployment would replace this with a SQL, LDAP, or any other + storage backend. + """ + + def __init__(self) -> None: + self.resources: dict[str, dict[str, Resource[Any]]] = defaultdict(dict) + + def query(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]: + try: + return self.resources[resource_type.name][resource_id] + except KeyError: + raise ResourceNotFoundError(resource_type, resource_id) from None + + def search( + self, + resource_types: list[ResourceType], + search_request: SearchRequest, + ) -> tuple[int, list[Resource[Any]]]: + resources = [ + resource + for resource_type in resource_types + for resource in self.resources[resource_type.name].values() + ] + if search_request.filter: + resources = [r for r in resources if search_request.filter.match(r)] + resources = search_request.sort(resources) + start = search_request.start_index_0 or 0 + count = ( + search_request.count if search_request.count is not None else MAX_RESULTS + ) + stop = start + min(count, MAX_RESULTS) + return len(resources), resources[start:stop] + + def create( + self, resource_type: ResourceType, resource: Resource[Any] + ) -> Resource[Any]: + self._check_user_name_unique(resource) + now = datetime.now(UTC) + resource.id = str(uuid4()) + resource.meta = Meta( + resource_type=resource_type.name, created=now, last_modified=now + ) + resource.meta.version = make_etag(resource) + self.resources[resource_type.name][resource.id] = resource + return resource + + def update( + self, resource_type: ResourceType, resource: Resource[Any] + ) -> Resource[Any]: + store = self.resources[resource_type.name] + if resource.id not in store: + raise ResourceNotFoundError(resource_type, resource.id) + self._check_user_name_unique(resource) + created = store[resource.id].meta.created if store[resource.id].meta else None + resource.meta = Meta( + resource_type=resource_type.name, + created=created, + last_modified=datetime.now(UTC), + ) + resource.meta.version = make_etag(resource) + store[resource.id] = resource + return resource + + def _check_user_name_unique(self, resource: Resource[Any]) -> None: + """Enforce the ``Uniqueness.global_`` scim2-models declares on ``User.userName``.""" + user_name = getattr(resource, "user_name", None) + if user_name is None: + return + for existing in ( + r for store in self.resources.values() for r in store.values() + ): + if ( + existing.id != resource.id + and getattr(existing, "user_name", None) == user_name + ): + raise UniquenessException( + attribute="userName", + value=user_name, + detail=f"userName {user_name!r} is already taken", + ) + + def delete(self, resource_type: ResourceType, resource_id: str) -> None: + try: + del self.resources[resource_type.name][resource_id] + except KeyError: + raise ResourceNotFoundError(resource_type, resource_id) from None + + +def create_provider() -> ScimProvider: + """Describe the resources served and the capabilities of :class:`InMemoryStorage`.""" + return ScimProvider( + models=[User, EnterpriseUser, Group], + resource_types=[ + ResourceType.from_resource(User[EnterpriseUser]), + ResourceType.from_resource(Group), + ], + config=ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk( + supported=True, + max_operations=MAX_BULK_OPERATIONS, + max_payload_size=MAX_BULK_PAYLOAD_SIZE, + ), + filter=Filter(supported=True, max_results=MAX_RESULTS), + change_password=ChangePassword(supported=False), + sort=Sort(supported=True), + etag=ETag(supported=True), + authentication_schemes=[], + ), + ) + + +def create_app() -> Flask: + app = Flask(__name__) + scim2 = SCIM2(InMemoryStorage(), create_provider()) + scim2.init_app(app) + return app + + +if __name__ == "__main__": + create_app().run(debug=True) diff --git a/pyproject.toml b/pyproject.toml index 56843d9..74d4f51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,13 +6,17 @@ build-backend = "uv_build" name = "scim2-flask" version = "0.0.0" description = "Painless SCIM integration for Flask" -readme = "README.md" authors = [ { name = "Éloi Rivard", email = "eloi@yaal.coop" } ] +license = "Apache-2.0" +license-files = ["LICENSE"] +readme = "README.md" +keywords = ["scim", "scim2", "provisioning", "flask", "rfc7643", "rfc7644"] requires-python = ">=3.11" dependencies = [ - "scim2-models>=0.6.9", + "flask>=3.1.3", + "scim2-models>=0.9.0", ] [project.urls] @@ -22,18 +26,62 @@ funding = "https://github.com/sponsors/python-scim" [dependency-groups] dev = [ - "prek>=0.1.0", - "pytest>=8.2.1", + "httpx2>=2.13.0", + "prek>=0.5.3", + "pytest>=9.1.1", + "pytest-cov>=7.1.0", + "scim2-tester>=0.4.0", ] doc = [ - "myst-parser>=3.0.1", - "shibuya>=2024.5.15", - "sphinx>=7.3.7", - "sphinx-issues>=5.0.0", + "shibuya>=2026.7.12", + "sphinx>=9.0.4", + "sphinx-issues>=6.0.0", + "sphinx-paramlinks>=0.6.0", + "sphinx-reredirects>=1.1.0", +] + +[tool.ruff.lint] +select = [ + "B", # flake8-bugbear + "D", # pydocstyle + "E", # pycodestyle + "F", # pyflakes + "I", # isort + "UP", # pyupgrade +] +ignore = [ + "E501", # line-too-long + "E722", # bare-except + "D100", # public module + "D101", # public class + "D102", # public method + "D103", # public function + "D104", # public package + "D105", # magic method + "D106", # nested class + "D107", # public init + "D203", # no-blank-line-before-class + "D213", # multi-line-summary-second-line +] + +[tool.ruff.lint.isort] +force-single-line = true + +[tool.ruff.format] +docstring-code-format = true + +[tool.coverage.run] +source = ["scim2_flask", "examples", "tests"] + +[tool.coverage.report] +exclude_also = [ + 'if __name__ == "__main__":', ] [tool.pytest.ini_options] -testpaths = ["tests"] +pythonpath = ["."] +addopts = "--doctest-modules --doctest-glob='*.rst'" +doctest_optionflags= "ALLOW_UNICODE ELLIPSIS" [tool.uv] exclude-newer = "14 days" diff --git a/src/scim2_flask/__init__.py b/src/scim2_flask/__init__.py index e69de29..39eab32 100644 --- a/src/scim2_flask/__init__.py +++ b/src/scim2_flask/__init__.py @@ -0,0 +1,5 @@ +from .extension import SCIM2 +from .storage import ResourceNotFoundError +from .storage import ScimStorage + +__all__ = ["SCIM2", "ResourceNotFoundError", "ScimStorage"] diff --git a/src/scim2_flask/extension.py b/src/scim2_flask/extension.py new file mode 100644 index 0000000..a17296e --- /dev/null +++ b/src/scim2_flask/extension.py @@ -0,0 +1,779 @@ +from __future__ import annotations + +from functools import reduce +from http import HTTPStatus +from operator import or_ +from typing import Any +from typing import cast + +from flask import Blueprint +from flask import Flask +from flask import Response +from flask import g +from flask import jsonify +from flask import request +from flask import url_for +from pydantic import ValidationError +from scim2_models import Bulk +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import BulkResponse +from scim2_models import ChangePassword +from scim2_models import Context +from scim2_models import Error +from scim2_models import ETag +from scim2_models import Filter +from scim2_models import InvalidFilterException +from scim2_models import ListResponse +from scim2_models import Meta +from scim2_models import Patch +from scim2_models import PatchOp +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import ResponseParameters +from scim2_models import Schema +from scim2_models import SCIMException +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig +from scim2_models import Sort +from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import HTTPException +from werkzeug.exceptions import NotFound +from werkzeug.exceptions import NotImplemented as HTTPNotImplemented +from werkzeug.exceptions import PreconditionFailed + +from .storage import ScimStorage + +EXTENSION_NAME = "scim2" + + +class PayloadTooLargeException(SCIMException): + """A bulk job beyond the limits the service provider announces. + + :rfc:`RFC7644 §3.7.4 <7644#section-3.7.4>`: "If either limit is + exceeded, the service provider MUST return HTTP response code 413 + (Payload Too Large)." No scimType of Table 9 goes with that status, so + the hierarchy scim2-models exposes is extended with it. + """ + + status = HTTPStatus.REQUEST_ENTITY_TOO_LARGE + + +class SCIM2: + """Flask extension exposing a SCIM 2.0 server backed by a :class:`ScimStorage`. + + The :class:`~scim2_models.ScimProvider` describes the service: the + resource types it serves, the configuration it announces, and the policy + the extension applies when it reads and writes payloads. + + Usage:: + + storage = MyStorage() + scim2 = SCIM2(storage, ScimProvider(models=[User])) + scim2.init_app(app) + + Or with the application factory pattern:: + + scim2 = SCIM2(storage, ScimProvider(models=[User])) + + + def create_app(): + app = Flask(__name__) + scim2.init_app(app) + return app + + Subclass :class:`SCIM2` and override its methods to customize behavior, + such as the resource location URL. + + :param storage: The storage the extension reads and writes resources in. + :param provider: The service description. It must declare at least one + resource type. + :param app: The application to register on right away, if any. + :param url_prefix: The URL prefix the SCIM endpoints are served under. + """ + + def __init__( + self, + storage: ScimStorage, + provider: ScimProvider, + app: Flask | None = None, + *, + url_prefix: str = "/scim/v2", + ) -> None: + if not provider.resource_types: + raise ValueError("SCIM2 extension requires at least one resource type") + + self.storage = storage + self.provider = provider + self.url_prefix = url_prefix + + if app is not None: + self.init_app(app) + + def init_app(self, app: Flask) -> None: + """Register the SCIM blueprint on ``app``, under ``url_prefix``. + + The extension is then available as ``app.extensions["scim2"]``. + """ + blueprint = self.create_blueprint() + app.register_blueprint(blueprint) + app.extensions[EXTENSION_NAME] = self + + def create_blueprint(self) -> Blueprint: + """Return the :class:`~flask.Blueprint` serving the SCIM endpoints. + + It holds the resource, search, discovery and bulk endpoints of every + resource type the provider declares, and the error handlers turning + failures into SCIM :class:`~scim2_models.Error` payloads. + """ + blueprint = Blueprint("scim2", __name__, url_prefix=self.url_prefix) + + # Payloads are read and written under the provider, and so under the + # policy it declares. + @blueprint.before_request + def _enter_provider() -> None: + self.provider.__enter__() + g.scim2_provider_entered = True + + @blueprint.teardown_request + def _exit_provider(_error: BaseException | None) -> None: + if g.pop("scim2_provider_entered", False): + self.provider.__exit__(None, None, None) + + @blueprint.after_request + def _set_content_type(response: Response) -> Response: + response.headers["Content-Type"] = "application/scim+json" + return response + + blueprint.register_error_handler(ValidationError, self.handle_validation_error) + blueprint.register_error_handler(SCIMException, self.handle_scim_exception) + blueprint.register_error_handler(HTTPException, self.handle_http_exception) + + for resource_type in self.provider.resource_types: + self._register_resource_routes(blueprint, resource_type) + + self._register_discovery_routes(blueprint) + self._register_bulk_route(blueprint) + + def me_view() -> Any: + # RFC7644 §3.11: "A service provider that does NOT support + # this feature SHOULD respond with HTTP status code 501 (Not + # Implemented)." + raise HTTPNotImplemented("/Me is not supported") + + blueprint.add_url_rule( + "/Me", "me", me_view, methods=["GET", "POST", "PUT", "PATCH", "DELETE"] + ) + + def not_found_view(_path: str) -> Any: + raise NotFound() + + blueprint.add_url_rule( + "/", + "not_found", + not_found_view, + methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + ) + + return blueprint + + # -- Overridable hooks ------------------------------------------- + + def get_service_provider_config(self) -> ServiceProviderConfig: + """Return the server's :class:`~scim2_models.ServiceProviderConfig`. + + This is the configuration the provider carries, if any. Give the + provider one, or override this method, to advertise the features + your :class:`ScimStorage` actually supports. + """ + if self.provider.config is not None: + return self.provider.config + return ServiceProviderConfig( + patch=Patch(supported=True), + bulk=Bulk(supported=False, max_operations=0, max_payload_size=0), + filter=Filter(supported=False, max_results=None), + change_password=ChangePassword(supported=False), + sort=Sort(supported=False), + etag=ETag(supported=False), + authentication_schemes=[], + ) + + def resource_location(self, resource_type: ResourceType, resource_id: str) -> str: + """Return the canonical URL of the resource identified by ``resource_id``. + + Every ``meta.location`` and bulk operation ``location`` is built here. + """ + slug = self._slug(resource_type) + return url_for(f"scim2.get_{slug}", resource_id=resource_id, _external=True) + + def handle_validation_error(self, error: ValidationError) -> tuple[dict, int]: + """Turn an invalid payload into a SCIM error response. + + The response reports only the first validation error. + """ + scim_error = Error.from_validation_error(error.errors()[0]) + return scim_error.model_dump(), scim_error.status + + def handle_scim_exception(self, error: SCIMException) -> tuple[dict, int]: + """Turn a :class:`~scim2_models.SCIMException` into a SCIM error response.""" + scim_error = error.to_error() + return scim_error.model_dump(), scim_error.status + + def handle_http_exception( + self, error: HTTPException + ) -> tuple[dict, int, list[tuple[str, str]]]: + """Turn a Werkzeug :class:`~werkzeug.exceptions.HTTPException` into a SCIM error response. + + The response keeps the headers of the exception, such as the + ``WWW-Authenticate`` of an :class:`~werkzeug.exceptions.Unauthorized`. + :rfc:`RFC7644 §2 <7644#section-2>`: "As per Section 4.1 of + [RFC7235], a SCIM service provider SHALL indicate supported HTTP + authentication schemes via the "WWW-Authenticate" header." + """ + scim_error = Error(status=error.code, detail=error.description) + headers = [ + (name, value) + for name, value in error.get_headers() + if name.lower() != "content-type" + ] + return scim_error.model_dump(), error.code or 500, headers + + # -- Resource types ---------------------------------------------- + + def _model(self, resource_type: ResourceType) -> type[Resource[Any]]: + """Return the model validating the resources of ``resource_type``.""" + return cast(type[Resource[Any]], self.provider.model_for(resource_type)) + + def _endpoint(self, resource_type: ResourceType) -> str: + return str(resource_type.endpoint).lstrip("/") + + def _slug(self, resource_type: ResourceType) -> str: + return str(resource_type.id).lower() + + def _resource_union(self) -> Any: + # Resource types sharing a schema share a model, listed once. + models = dict.fromkeys(map(self._model, self.provider.resource_types)) + return reduce(or_, models) + + def _resource_type_of(self, resource: Resource[Any]) -> ResourceType: + """Return the resource type a resource from the storage belongs to. + + The storage records it in ``meta.resourceType``, since two resource + types may share a model. + """ + name = resource.meta.resource_type if resource.meta else None + for resource_type in self.provider.resource_types: + if name is not None and resource_type.name == name: + return resource_type + raise ValueError( + f"The storage returned a resource whose meta.resourceType, {name!r}, " + "names no resource type the provider declares" + ) + + def _resource_type_at(self, endpoint: str) -> ResourceType | None: + """Return the resource type served at ``endpoint``, if any.""" + key = endpoint.lstrip("/").casefold() + for resource_type in self.provider.resource_types: + if self._endpoint(resource_type).casefold() == key: + return resource_type + return None + + # -- Routes ------------------------------------------------------ + + def _register_resource_routes( + self, blueprint: Blueprint, resource_type: ResourceType + ) -> None: + endpoint = self._endpoint(resource_type) + slug = self._slug(resource_type) + model = self._model(resource_type) + + def search(search_request: SearchRequest[Any], scim_ctx: Context) -> Any: + self._check_filter_supported(search_request) + total, resources = self.storage.search([resource_type], search_request) + for resource in resources: + self._with_meta(resource_type, resource) + response = ListResponse[model]( + total_results=total, + start_index=search_request.start_index or 1, + items_per_page=len(resources), + resources=resources, + ) + return response.model_dump( + scim_ctx=scim_ctx, + response_parameters=search_request, + ) + + def list_view() -> Any: + search_request = SearchRequest[model].model_validate(request.args.to_dict()) + return search(search_request, Context.RESOURCE_QUERY_RESPONSE) + + def search_view() -> Any: + search_request = SearchRequest[model].model_validate_json( + request.data, scim_ctx=Context.SEARCH_REQUEST + ) + return search(search_request, Context.SEARCH_RESPONSE) + + def create_view() -> Any: + response_parameters = ResponseParameters.model_validate( + request.args.to_dict() + ) + payload = model.model_validate_json( + request.data, scim_ctx=Context.RESOURCE_CREATION_REQUEST + ) + created = self.storage.create(resource_type, payload) + return self._resource_response( + resource_type, + created, + { + "scim_ctx": Context.RESOURCE_CREATION_RESPONSE, + "response_parameters": response_parameters, + }, + HTTPStatus.CREATED, + ) + + def get_view(resource_id: str) -> Any: + response_parameters = ResponseParameters.model_validate( + request.args.to_dict() + ) + resource = self.storage.query(resource_type, resource_id) + return self._resource_response( + resource_type, + resource, + { + "scim_ctx": Context.RESOURCE_QUERY_RESPONSE, + "response_parameters": response_parameters, + }, + ) + + blueprint.add_url_rule( + f"/{endpoint}", f"list_{slug}", list_view, methods=["GET"] + ) + blueprint.add_url_rule( + f"/{endpoint}", f"create_{slug}", create_view, methods=["POST"] + ) + blueprint.add_url_rule( + f"/{endpoint}/", f"get_{slug}", get_view, methods=["GET"] + ) + blueprint.add_url_rule( + f"/{endpoint}/.search", f"search_{slug}", search_view, methods=["POST"] + ) + + def replace_view(resource_id: str) -> Any: + response_parameters = ResponseParameters.model_validate( + request.args.to_dict() + ) + original = self.storage.query(resource_type, resource_id) + self._check_if_match(original) + payload = model.model_validate_json( + request.data, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST + ) + payload.replace(original) + updated = self.storage.update(resource_type, payload) + return self._resource_response( + resource_type, + updated, + { + "scim_ctx": Context.RESOURCE_REPLACEMENT_RESPONSE, + "response_parameters": response_parameters, + }, + ) + + def patch_view(resource_id: str) -> Any: + response_parameters = ResponseParameters.model_validate( + request.args.to_dict() + ) + if not self._patch_supported(): + raise HTTPNotImplemented("PATCH is not supported") + resource = self.storage.query(resource_type, resource_id) + self._check_if_match(resource) + patch_op = PatchOp[model].model_validate_json( + request.data, scim_ctx=Context.RESOURCE_PATCH_REQUEST + ) + if patch_op.patch(resource): + resource = self.storage.update(resource_type, resource) + return self._resource_response( + resource_type, + resource, + { + "scim_ctx": Context.RESOURCE_PATCH_RESPONSE, + "response_parameters": response_parameters, + }, + ) + + def delete_view(resource_id: str) -> Any: + if request.if_match: + self._check_if_match(self.storage.query(resource_type, resource_id)) + self.storage.delete(resource_type, resource_id) + return "", HTTPStatus.NO_CONTENT + + blueprint.add_url_rule( + f"/{endpoint}/", + f"replace_{slug}", + replace_view, + methods=["PUT"], + ) + blueprint.add_url_rule( + f"/{endpoint}/", f"patch_{slug}", patch_view, methods=["PATCH"] + ) + blueprint.add_url_rule( + f"/{endpoint}/", + f"delete_{slug}", + delete_view, + methods=["DELETE"], + ) + + def _register_discovery_routes(self, blueprint: Blueprint) -> None: + def _reject_filter() -> None: + """:rfc:`RFC7644 §4 <7644#section-4>`. + + "Query parameters described in Section 3.4.2, such as + filtering, sorting, and pagination, SHALL be ignored. If a + "filter" is provided, the service provider SHOULD respond + with HTTP status code 403 (Forbidden) to ensure that clients + cannot incorrectly assume that any matching conditions + specified in a filter are true." + """ + if request.args.get("filter"): + raise Forbidden("Discovery endpoints do not support filtering") + + @blueprint.get("/ServiceProviderConfig") + def service_provider_config() -> Any: + _reject_filter() + return self.get_service_provider_config().model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + + @blueprint.get("/ResourceTypes") + def list_resource_types() -> Any: + _reject_filter() + resource_types = self.provider.resource_types + response = ListResponse[ResourceType]( + total_results=len(resource_types), + start_index=1, + items_per_page=len(resource_types), + resources=resource_types, + ) + return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + @blueprint.post("/.search") + def search_root() -> Any: + resource_union = self._resource_union() + search_request = SearchRequest[resource_union].model_validate_json( + request.data, scim_ctx=Context.SEARCH_REQUEST + ) + self._check_filter_supported(search_request) + total, resources = self.storage.search( + list(self.provider.resource_types), search_request + ) + for resource in resources: + self._with_meta(self._resource_type_of(resource), resource) + response = ListResponse[resource_union]( + total_results=total, + start_index=search_request.start_index or 1, + items_per_page=len(resources), + resources=resources, + ) + return response.model_dump( + scim_ctx=Context.SEARCH_RESPONSE, + response_parameters=search_request, + ) + + @blueprint.get("/ResourceTypes/") + def get_resource_type_view(name: str) -> Any: + for resource_type in self.provider.resource_types: + if resource_type.id == name: + return resource_type.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE + ) + raise NotFound(f"ResourceType {name!r} not found") + + @blueprint.get("/Schemas") + def list_schemas() -> Any: + _reject_filter() + schemas = self.provider.schemas + response = ListResponse[Schema]( + total_results=len(schemas), + start_index=1, + items_per_page=len(schemas), + resources=schemas, + ) + return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + + @blueprint.get("/Schemas/") + def get_schema_view(schema_id: str) -> Any: + for schema in self.provider.schemas: + if schema.id == schema_id: + return schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE) + raise NotFound(f"Schema {schema_id!r} not found") + + def _register_bulk_route(self, blueprint: Blueprint) -> None: + @blueprint.post("/Bulk") + def bulk() -> Any: + bulk_config = self.get_service_provider_config().bulk + if bulk_config is None or not bulk_config.supported: + raise HTTPNotImplemented("Bulk operations are not supported") + # RFC7644 §3.7.4: "The service provider MUST define the + # maximum number of operations and maximum payload size a + # client may send in a single request. [...] If either limit + # is exceeded, the service provider MUST return HTTP response + # code 413 (Payload Too Large)." + payload_size = len(request.data) + if ( + bulk_config.max_payload_size is not None + and payload_size > bulk_config.max_payload_size + ): + raise PayloadTooLargeException( + detail=( + "The size of the bulk operation exceeds the " + f"maxPayloadSize ({bulk_config.max_payload_size})." + ) + ) + + bulk_request = BulkRequest[self._resource_union()].model_validate_json( + request.data, scim_ctx=Context.BULK_REQUEST + ) + operations = bulk_request.operations or [] + # RFC7644 §3.7.4: "If either limit is exceeded, the service + # provider MUST return HTTP response code 413 (Payload Too + # Large)." + if ( + bulk_config.max_operations is not None + and len(operations) > bulk_config.max_operations + ): + raise PayloadTooLargeException( + detail=( + "The number of operations exceeds the " + f"maxOperations ({bulk_config.max_operations})." + ) + ) + + results = [] + errors = 0 + for operation in operations: + self._run_bulk_operation(operation) + results.append(operation) + if ( + operation.status is not None + and operation.status < HTTPStatus.BAD_REQUEST + ): + continue + # RFC7644 §3.7: "The service provider MUST continue + # performing as many changes as possible and disregard + # partial failures. The client MAY override this behavior by + # specifying a value for the "failOnErrors" attribute." + errors += 1 + if ( + bulk_request.fail_on_errors + and errors >= bulk_request.fail_on_errors + ): + break + + response = BulkResponse[self._resource_union()](operations=results) + return response.model_dump(scim_ctx=Context.BULK_RESPONSE) + + # -- Request checks ---------------------------------------------- + + def _check_filter_supported(self, search_request: SearchRequest[Any]) -> None: + """Refuse a filter the service provider does not announce. + + :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`: "Providers MUST + decline to filter results if the specified filter operation is not + recognized and return an HTTP 400 error with a "scimType" error of + "invalidFilter" and an appropriate human-readable response as per + Section 3.12." + """ + filter_config = self.get_service_provider_config().filter + if search_request.filter and not (filter_config and filter_config.supported): + raise InvalidFilterException( + detail="Filtering is not supported by this service provider." + ) + + def _patch_supported(self) -> bool: + """Tell whether the service provider announces PATCH. + + :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "501 (Not + Implemented)": "Service provider does not support the request + operation, e.g., PATCH." + """ + patch_config = self.get_service_provider_config().patch + return bool(patch_config and patch_config.supported) + + def _check_if_match(self, resource: Resource[Any]) -> None: + """:rfc:`RFC7644 §3.14 <7644#section-3.14>`. + + "If the service provider supports versioning of resources, the + client MAY supply an If-Match header (Section 3.1 of [RFC7232]) for + PUT and PATCH operations to ensure that the requested operation + succeeds only if the supplied ETag matches the latest service + provider resource [...]." + """ + if not request.if_match: + return + version = resource.meta.version if resource.meta else None + if version is None: + return + if not request.if_match.contains_raw(version): + raise PreconditionFailed("ETag mismatch") + + # -- Bulk operations --------------------------------------------- + + def _resolve_bulk_target(self, path: str) -> tuple[ResourceType | None, str | None]: + """Resolve a bulk operation's ``path`` to the resource type (and id, if any) it targets.""" + resource_type = self._resource_type_at(path) + if resource_type is not None: + return resource_type, None + endpoint, _, resource_id = path.rpartition("/") + return self._resource_type_at(endpoint), resource_id + + def _run_bulk_operation(self, operation: BulkOperation[Any]) -> None: + """Apply one bulk operation, and turn it into the description of its outcome. + + The target is resolved before the operation is applied, so a + failure still knows its location. :rfc:`RFC7644 §3.7 + <7644#section-3.7>`: "location The resource endpoint URL. REQUIRED + in a response, except in the event of a POST failure." + """ + expected_version = operation.version + operation.version = None + assert operation.path is not None + + resource_type, resource_id = self._resolve_bulk_target(operation.path) + if resource_type is None: + if operation.method != BulkOperation.Method.post: + # RFC7644 §3.7.3: "A "location" attribute that includes + # the resource's endpoint MUST be returned for all operations + # except for failed POST operations (which have no + # location)." That holds even when no resource type answers + # the path. + operation.location = url_for( + "scim2.not_found", + _path=operation.path.lstrip("/"), + _external=True, + ) + operation.status = HTTPStatus.NOT_FOUND + operation.response = Error( + status=HTTPStatus.NOT_FOUND, + detail=f"{operation.path!r} does not designate a known resource type", + ) + return + + if operation.method != BulkOperation.Method.post: + # RFC7644 §3.7.3: "A "location" attribute that includes the + # resource's endpoint MUST be returned for all operations + # except for failed POST operations (which have no + # location)." So it is set once here, ahead of success or + # failure, rather than duplicated in every branch below. + assert resource_id is not None + operation.location = self.resource_location(resource_type, resource_id) + + if ( + operation.method == BulkOperation.Method.patch + and not self._patch_supported() + ): + operation.status = HTTPStatus.NOT_IMPLEMENTED + operation.response = Error( + status=HTTPStatus.NOT_IMPLEMENTED, detail="PATCH is not supported" + ) + return + + try: + if operation.method == BulkOperation.Method.post: + created = self.storage.create(resource_type, operation.data) + meta = self._with_meta(resource_type, created) + operation.status = HTTPStatus.CREATED + operation.location = meta.location + operation.version = meta.version + return + + assert resource_id is not None + original = self.storage.query(resource_type, resource_id) + + # RFC7644 §3.7: "Version MAY be used if the service provider + # supports entity-tags (ETags) (Section 2.3 of [RFC7232]) and + # "method" is "PUT", "PATCH", or "DELETE"." + current_version = original.meta.version if original.meta else None + if ( + expected_version is not None + and current_version is not None + and expected_version != current_version + ): + operation.status = HTTPStatus.PRECONDITION_FAILED + operation.response = Error( + status=HTTPStatus.PRECONDITION_FAILED, detail="ETag mismatch" + ) + return + + if operation.method == BulkOperation.Method.delete: + self.storage.delete(resource_type, resource_id) + operation.status = HTTPStatus.NO_CONTENT + return + + if operation.method == BulkOperation.Method.patch: + if operation.data.patch(original): + original = self.storage.update(resource_type, original) + else: + operation.data.replace(original) + original = self.storage.update(resource_type, operation.data) + + meta = self._with_meta(resource_type, original) + operation.status = HTTPStatus.OK + operation.version = meta.version + return + + except SCIMException as exc: + operation.status = exc.status + operation.response = exc.to_error() + return + + # -- Responses --------------------------------------------------- + + def _with_meta(self, resource_type: ResourceType, resource: Resource[Any]) -> Meta: + """Check the ``meta`` of a resource from the storage, and complete it. + + :return: The ``meta`` of the resource, once completed. + """ + if resource.meta is None or resource.meta.resource_type != resource_type.name: + name = resource.meta.resource_type if resource.meta else None + raise ValueError( + f"The storage returned a {resource_type.name} resource whose " + f"meta.resourceType is {name!r}" + ) + assert resource.id is not None + resource.meta.location = self.resource_location(resource_type, resource.id) + return resource.meta + + def _resource_response( + self, + resource_type: ResourceType, + resource: Resource[Any], + dump_kwargs: dict[str, Any], + status: int = HTTPStatus.OK, + ) -> Response: + """Build a single-resource response. + + :rfc:`RFC7643 §3.1 <7643#section-3.1>`: "location The URI of the + resource being returned. This value MUST be the same as the + "Content-Location" HTTP response header (see Section 3.1.4.2 of + [RFC7231])." + + :rfc:`RFC7644 §3.3 <7644#section-3.3>`: "The URI of the created + resource SHALL include, in the HTTP "Location" header and the HTTP + body, a JSON representation [RFC7159] with the attribute + "meta.location"." + + :rfc:`RFC7644 §3.14 <7644#section-3.14>`: "When supported, SCIM ETags + MUST be specified as an HTTP header and SHOULD be specified within + the 'version' attribute contained in the resource's 'meta' + attribute." + """ + meta = self._with_meta(resource_type, resource) + response = jsonify(resource.model_dump(**dump_kwargs)) + response.status_code = status + response.headers["Content-Location"] = meta.location + if status == HTTPStatus.CREATED: + response.headers["Location"] = meta.location + if meta.version: + response.headers["ETag"] = meta.version + # Answers a GET carrying a matching If-None-Match with a 304. + return response.make_conditional(request) diff --git a/src/scim2_flask/storage.py b/src/scim2_flask/storage.py new file mode 100644 index 0000000..b4d215c --- /dev/null +++ b/src/scim2_flask/storage.py @@ -0,0 +1,92 @@ +from abc import ABC +from abc import abstractmethod +from http import HTTPStatus +from typing import Any + +from scim2_models import Resource +from scim2_models import ResourceType +from scim2_models import SCIMException +from scim2_models import SearchRequest + + +class ResourceNotFoundError(SCIMException): + """Raised by a :class:`ScimStorage` when no resource matches an id. + + :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "404 (Not Found)": + "Specified resource (e.g., User) or endpoint does not exist." + """ + + status = HTTPStatus.NOT_FOUND + + def __init__(self, resource_type: ResourceType, resource_id: str): + self.resource_type = resource_type + self.resource_id = resource_id + super().__init__(detail=f"{resource_type.name} {resource_id!r} not found") + + +class ScimStorage(ABC): + """Where :class:`~scim2_flask.SCIM2` reads and writes SCIM resources. + + Subclass it to connect the SCIM server to your own data (SQL, LDAP, + in-memory, ...). The server handles the SCIM protocol and calls these + methods to query, search, create, update and delete resources. + + Every method receives the :class:`~scim2_models.ResourceType` it applies + to, so a single storage can serve several resource types. + """ + + @abstractmethod + def query(self, resource_type: ResourceType, resource_id: str) -> Resource[Any]: + """Return the resource of ``resource_type`` identified by ``resource_id``. + + :raises ResourceNotFoundError: if no such resource exists. + """ + + @abstractmethod + def search( + self, + resource_types: list[ResourceType], + search_request: SearchRequest, + ) -> tuple[int, list[Resource[Any]]]: + """Return the total count and one page of the matching resources. + + ``resource_types`` holds a single resource type for a search on its + endpoint (such as ``/Users`` or ``/Groups``), and all of them for a + search at the server root (``/.search``): filter, sort and page them + as a single collection. An attribute a resource type does not declare + matches none of its resources. + + :param resource_types: The resource types to search. + :param search_request: The query: ``filter``, ``sort_by``, + ``sort_order``, ``start_index_0`` and ``stop_index_0``. + :return: ``(total_results, page)``, the page holding at most + ``maxResults`` resources. + :raises ~scim2_models.TooManyException: if the query yields more + results than the storage is willing to process. + """ + + @abstractmethod + def create( + self, resource_type: ResourceType, resource: Resource[Any] + ) -> Resource[Any]: + """Persist ``resource`` and return the stored representation.""" + + @abstractmethod + def update( + self, resource_type: ResourceType, resource: Resource[Any] + ) -> Resource[Any]: + """Persist ``resource``, identified by its own ``id``, and return the stored representation. + + Used for both PUT replacements and PATCH modifications: in both + cases the caller has already produced the resource's full wanted + state and only asks for it to be saved. + + :raises ResourceNotFoundError: if no resource matches ``resource.id``. + """ + + @abstractmethod + def delete(self, resource_type: ResourceType, resource_id: str) -> None: + """Remove the resource of ``resource_type`` identified by ``resource_id``. + + :raises ResourceNotFoundError: if no such resource exists. + """ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..cc706ac --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +from collections.abc import Callable + +import pytest +from flask import Flask +from scim2_client.engines.werkzeug import TestSCIMClient +from werkzeug.test import Client + +from examples.minimal_server import create_app + + +@pytest.fixture +def app() -> Flask: + return create_app() + + +@pytest.fixture +def client(app: Flask) -> Client: + return Client(app) + + +@pytest.fixture +def make_scim_client() -> Callable[[Flask], TestSCIMClient]: + """Build a SCIM client for an app a test sets up with its own storage.""" + + def make(app: Flask) -> TestSCIMClient: + return TestSCIMClient( + Client(app), + scim_prefix="/scim/v2", + provider=app.extensions["scim2"].provider, + ) + + return make + + +@pytest.fixture +def scim_client(app: Flask, make_scim_client) -> TestSCIMClient: + return make_scim_client(app) diff --git a/tests/test_bulk.py b/tests/test_bulk.py new file mode 100644 index 0000000..f2d3d42 --- /dev/null +++ b/tests/test_bulk.py @@ -0,0 +1,238 @@ +"""Tests for the POST /Bulk endpoint, RFC7644 §3.7.""" + +import io + +import pytest +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import Context +from scim2_models import EnterpriseUser +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import SCIMException +from scim2_models import User + + +def test_bulk_dispatches_operations_by_method(scim_client): + # RFC7644 §3.7: "The body of a bulk operation contains a set of HTTP + # resource operations using one of the HTTP methods supported by the + # API, i.e., POST, PUT, PATCH, or DELETE." + create = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="u1", + path="/Users", + data=User[EnterpriseUser](user_name="bulk-created"), + ) + ] + ) + ) + assert create.operations[0].status == 201 + uid = create.operations[0].location.rsplit("/", 1)[-1] + + modify = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="PATCH", + bulk_id="p1", + path=f"/Users/{uid}", + data=PatchOp[User[EnterpriseUser]]( + operations=[ + PatchOperation( + op="replace", path="displayName", value="Bulked" + ) + ] + ), + ), + BulkOperation[User[EnterpriseUser]]( + method="PUT", + bulk_id="r1", + path=f"/Users/{uid}", + data=User[EnterpriseUser]( + user_name="bulk-created", display_name="Replaced" + ), + ), + BulkOperation[User[EnterpriseUser]]( + method="DELETE", bulk_id="d1", path=f"/Users/{uid}" + ), + ] + ) + ) + assert [op.status for op in modify.operations] == [200, 200, 204] + + with pytest.raises(SCIMException) as exc_info: + scim_client.query(User[EnterpriseUser], uid) + assert exc_info.value.status == 404 + + +def post_bulk(client, bulk_request, chunked=False): + """Send a bulk request the way a client ignoring the announced limits does. + + scim2-client refuses to send a job beyond the limits the provider + announces, so the server side is exercised through a raw request. A + chunked request announces no Content-Length. + """ + body = bulk_request.model_dump_json(scim_ctx=Context.BULK_REQUEST).encode() + if not chunked: + return client.post( + "/scim/v2/Bulk", data=body, content_type="application/scim+json" + ) + return client.post( + "/scim/v2/Bulk", + input_stream=io.BytesIO(body), + headers={ + "Content-Type": "application/scim+json", + "Transfer-Encoding": "chunked", + }, + environ_overrides={"wsgi.input_terminated": True}, + ) + + +def test_bulk_rejects_job_exceeding_max_operations(client): + # RFC7644 §3.7.4: "If either limit is exceeded, the service provider + # MUST return HTTP response code 413 (Payload Too Large)." + bulk_request = BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id=f"u{i}", + path="/Users", + data=User[EnterpriseUser](user_name=f"bulk{i}"), + ) + for i in range(101) + ] + ) + response = post_bulk(client, bulk_request) + assert response.status_code == 413 + assert "maxOperations" in response.json["detail"] + + +def test_bulk_stops_after_fail_on_errors(scim_client): + # RFC7644 §3.7: "The "failOnErrors" attribute defines the number of + # errors that the service provider should accept before failing the + # remaining operations returning the response." + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + fail_on_errors=1, + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="DELETE", bulk_id="bad", path="/Users/does-not-exist" + ), + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="never", + path="/Users", + data=User[EnterpriseUser](user_name="unreached"), + ), + ], + ) + ) + assert [op.status for op in response.operations] == [404] + + +def test_bulk_operation_errors_are_embedded_per_operation(scim_client): + # RFC7644 §3.7: "The service provider MUST continue performing as many + # changes as possible and disregard partial failures." + scim_client.create(User[EnterpriseUser](user_name="taken")) + + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="DELETE", bulk_id="unknown-path", path="/Bogus/xyz" + ), + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="dupe", + path="/Users", + data=User[EnterpriseUser](user_name="taken"), + ), + ] + ) + ) + unknown, dupe = response.operations + assert unknown.status == 404 + # RFC7644 §3.7.3: "A "location" attribute that includes the resource's + # endpoint MUST be returned for all operations except for failed POST + # operations (which have no location)." That holds even when the path + # designates no resource type. + assert unknown.location == "http://localhost/scim/v2/Bogus/xyz" + assert dupe.status == 409 + assert dupe.response.scim_type == "uniqueness" + + +@pytest.mark.parametrize("chunked", [False, True]) +def test_bulk_rejects_job_exceeding_max_payload_size(client, chunked): + # RFC7644 §3.7.4: "The service provider MUST define the maximum + # number of operations and maximum payload size a client may send in + # a single request. [...] If either limit is exceeded, the service + # provider MUST return HTTP response code 413 (Payload Too Large)." + bulk_request = BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="u1", + path="/Users", + data=User[EnterpriseUser](user_name="x" * 2_000_000), + ) + ] + ) + response = post_bulk(client, bulk_request, chunked=chunked) + assert response.status_code == 413 + assert "maxPayloadSize" in response.json["detail"] + + +def test_bulk_stale_operation_version_returns_412(scim_client): + # RFC7644 §3.7: "Version MAY be used if the service provider supports + # entity-tags (ETags) (Section 2.3 of [RFC7232]) and "method" is "PUT", + # "PATCH", or "DELETE"." + created = scim_client.create(User[EnterpriseUser](user_name="bulk-versioned")) + + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="PUT", + bulk_id="v1", + path=f"/Users/{created.id}", + version='W/"stale"', + data=User[EnterpriseUser]( + user_name="bulk-versioned", display_name="Should Not Stick" + ), + ) + ] + ) + ) + assert response.operations[0].status == 412 + # RFC7644 §3.7.3: "A "location" attribute that includes the resource's + # endpoint MUST be returned for all operations except for failed POST + # operations (which have no location)." So it must be present even on + # this 412. + assert response.operations[0].location.endswith(f"/Users/{created.id}") + # The version of a result is the one of the resource, never the one the + # client expected. + assert response.operations[0].version is None + + reloaded = scim_client.query(User[EnterpriseUser], created.id) + assert reloaded.display_name is None + + +def test_bulk_delete_result_carries_no_version(scim_client): + created = scim_client.create(User[EnterpriseUser](user_name="bulk-deleted")) + + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="DELETE", + path=f"/Users/{created.id}", + version=created.meta.version, + ) + ] + ) + ) + assert response.operations[0].status == 204 + assert response.operations[0].version is None diff --git a/tests/test_enterprise_user.py b/tests/test_enterprise_user.py new file mode 100644 index 0000000..fc65060 --- /dev/null +++ b/tests/test_enterprise_user.py @@ -0,0 +1,76 @@ +import pytest +from scim2_models import EnterpriseUser +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import ResourceType +from scim2_models import Schema +from scim2_models import SchemaExtension +from scim2_models import SearchRequest +from scim2_models import User + + +@pytest.fixture +def user(scim_client): + return scim_client.create( + User[EnterpriseUser]( + user_name="bjensen", + EnterpriseUser=EnterpriseUser(employee_number="42"), + ) + ) + + +def test_extension_attributes_are_stored_and_returned(scim_client, user): + reloaded = scim_client.query(User[EnterpriseUser], user.id) + assert reloaded.schemas == [User.__schema__, EnterpriseUser.__schema__] + assert reloaded[EnterpriseUser].employee_number == "42" + + +def test_meta_resource_type_is_the_resource_type_name(user): + # RFC7643 §3.1: "resourceType The name of the resource type of the + # resource." The name of the model carrying the extension is not that + # name. + assert user.meta.resource_type == "User" + + +def test_not_found_detail_names_the_resource_type(client): + response = client.get("/scim/v2/Users/unknown") + assert response.status_code == 404 + assert response.json["detail"] == "User 'unknown' not found" + + +def test_filter_on_extension_attribute(scim_client, user): + response = scim_client.query( + User[EnterpriseUser], + query_parameters=SearchRequest( + filter=f'{EnterpriseUser.__schema__}:employeeNumber eq "42"' + ), + ) + assert [u.id for u in response.resources] == [user.id] + + +def test_patch_extension_attribute(scim_client, user): + patch_op = PatchOp[User[EnterpriseUser]]( + operations=[ + PatchOperation( + op="replace", + path=f"{EnterpriseUser.__schema__}:department", + value="R&D", + ) + ] + ) + patched = scim_client.modify(User[EnterpriseUser], user.id, patch_op) + assert patched[EnterpriseUser].employee_number == "42" + assert patched[EnterpriseUser].department == "R&D" + + +def test_discovery_announces_the_extension(scim_client): + # RFC7643 §6: "schemaExtensions A list of URIs of the resource type's + # schema extensions." + resource_type = scim_client.query(ResourceType, "User") + assert resource_type.schema_ == User.__schema__ + assert resource_type.schema_extensions == [ + SchemaExtension(schema_=EnterpriseUser.__schema__, required=False) + ] + + schemas = scim_client.query(Schema) + assert EnterpriseUser.__schema__ in [schema.id for schema in schemas.resources] diff --git a/tests/test_etag.py b/tests/test_etag.py new file mode 100644 index 0000000..960aa7b --- /dev/null +++ b/tests/test_etag.py @@ -0,0 +1,116 @@ +"""Tests for resource versioning (ETags), RFC7644 §3.14.""" + +import pytest +from flask import Flask +from scim2_models import EnterpriseUser +from scim2_models import ETag +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import SCIMException +from scim2_models import ScimProvider +from scim2_models import ServiceProviderConfig +from scim2_models import User + +from examples.minimal_server import InMemoryStorage +from scim2_flask import SCIM2 + + +def test_etag_header_mirrors_meta_version(client): + # RFC7644 §3.14: "When supported, SCIM ETags MUST be specified as an + # HTTP header and SHOULD be specified within the 'version' attribute + # contained in the resource's 'meta' attribute." The SCIM client only + # exposes the payload, so the header is read from the raw response. + r = client.post( + "/scim/v2/Users", + json={"schemas": [User.__schema__], "userName": "etagged"}, + ) + assert r.headers["ETag"] == r.get_json()["meta"]["version"] + + +def test_conditional_get_returns_304(scim_client): + # RFC7644 §3.14: "If the resource has not changed, the service + # provider simply returns an empty body with a 304 (Not Modified) + # response code." + created = scim_client.create(User[EnterpriseUser](user_name="etagged")) + etag = created.meta.version + + not_modified = scim_client.query( + User[EnterpriseUser], created.id, headers={"If-None-Match": etag} + ) + assert not_modified is None + + reloaded = scim_client.query( + User[EnterpriseUser], created.id, headers={"If-None-Match": 'W/"stale"'} + ) + assert reloaded.id == created.id + + +def test_stale_if_match_returns_412(scim_client): + # RFC7644 §3.14: "If the service provider supports versioning of + # resources, the client MAY supply an If-Match header (Section 3.1 of + # [RFC7232]) for PUT and PATCH operations to ensure that the requested + # operation succeeds only if the supplied ETag matches the latest + # service provider resource [...]." + # RFC7644 §3.12, Table 8, "412 (Precondition Failed)": "Failed to + # update. Resource has changed on the server." + created = scim_client.create(User[EnterpriseUser](user_name="stale")) + stale_etag = created.meta.version + + def replace_display_name(value): + return PatchOp[User[EnterpriseUser]]( + operations=[PatchOperation(op="replace", path="displayName", value=value)] + ) + + scim_client.modify( + User[EnterpriseUser], + created.id, + replace_display_name("First"), + headers={"If-Match": stale_etag}, + ) + + with pytest.raises(SCIMException) as exc_info: + scim_client.modify( + User[EnterpriseUser], + created.id, + replace_display_name("Second"), + headers={"If-Match": stale_etag}, + ) + assert exc_info.value.status == 412 + + reloaded = scim_client.query(User[EnterpriseUser], created.id) + assert reloaded.display_name == "First" + + +def test_if_match_wildcard_bypasses_version_check(scim_client): + created = scim_client.create(User[EnterpriseUser](user_name="wildcard")) + scim_client.delete(User[EnterpriseUser], created.id, headers={"If-Match": "*"}) + + with pytest.raises(SCIMException) as exc_info: + scim_client.query(User[EnterpriseUser], created.id) + assert exc_info.value.status == 404 + + +def test_if_match_is_a_noop_without_meta_version(make_scim_client): + """`If-Match` must be ignored, not rejected, on a resource without version. + + Versioning is announced, but the storage does not set `meta.version`. + """ + + class UnversionedStorage(InMemoryStorage): + def create(self, resource_type, resource): + resource = super().create(resource_type, resource) + resource.meta.version = None + return resource + + app = Flask(__name__) + config = ServiceProviderConfig(etag=ETag(supported=True)) + SCIM2(UnversionedStorage(), ScimProvider(models=[User], config=config), app=app) + scim_client = make_scim_client(app) + + created = scim_client.create(User(user_name="unversioned")) + assert created.meta.version is None + + scim_client.delete(User, created.id, headers={"If-Match": 'W/"anything"'}) + with pytest.raises(SCIMException) as exc_info: + scim_client.query(User, created.id) + assert exc_info.value.status == 404 diff --git a/tests/test_extension.py b/tests/test_extension.py new file mode 100644 index 0000000..a696dd9 --- /dev/null +++ b/tests/test_extension.py @@ -0,0 +1,366 @@ +import pytest +from flask import Flask +from scim2_models import EnterpriseUser +from scim2_models import Meta +from scim2_models import MutabilityException +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import ResourceType +from scim2_models import Schema +from scim2_models import SCIMException +from scim2_models import ScimPolicy +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig +from scim2_models import User +from werkzeug.datastructures import WWWAuthenticate +from werkzeug.exceptions import Unauthorized +from werkzeug.test import Client + +from examples.minimal_server import InMemoryStorage +from examples.minimal_server import create_provider +from scim2_flask import SCIM2 + + +def test_validation_error_returns_scim_error(client): + # A payload that is not even JSON cannot be built with the SCIM client. + r = client.post("/scim/v2/Users", data=b"{") + assert r.status_code == 400 + assert r.get_json()["scimType"] == "invalidSyntax" + + +def test_me_returns_not_implemented(client): + # RFC7644 §3.11: "A service provider that does NOT support this + # feature SHOULD respond with HTTP status code 501 (Not + # Implemented)." The SCIM client has no call for /Me. + assert client.get("/scim/v2/Me").status_code == 501 + + +@pytest.mark.parametrize("model", [Schema, ResourceType, ServiceProviderConfig]) +def test_discovery_endpoints_reject_filter(scim_client, model): + # RFC7644 §4: "If a "filter" is provided, the service provider SHOULD + # respond with HTTP status code 403 (Forbidden) to ensure that clients + # cannot incorrectly assume that any matching conditions specified in + # a filter are true." + with pytest.raises(SCIMException) as exc_info: + scim_client.query( + model, query_parameters=SearchRequest(filter='userName eq "x"') + ) + assert exc_info.value.status == 403 + + +def test_patch_is_all_or_nothing(scim_client): + # RFC7644 §3.5.2: "A PATCH request, regardless of the number of + # operations, SHALL be treated as atomic. If a single operation + # encounters an error condition, the original SCIM resource MUST be + # restored, and a failure status SHALL be returned." The client would + # refuse to send a PatchOp targeting the read-only "id", so the payload + # is sent unchecked to reach the server check. + created = scim_client.create(User[EnterpriseUser](user_name="atomic")) + patch = { + "schemas": [str(PatchOp.__schema__)], + "Operations": [ + {"op": "replace", "path": "displayName", "value": "Should Not Stick"}, + {"op": "replace", "path": "id", "value": "hacked"}, + ], + } + with pytest.raises(MutabilityException) as exc_info: + scim_client.modify( + User[EnterpriseUser], created.id, patch, check_request_payload=False + ) + assert exc_info.value.status == 400 + + reloaded = scim_client.query(User[EnterpriseUser], created.id) + assert reloaded.display_name is None + + +def test_patch_noop_does_not_bump_last_modified(scim_client): + # RFC7644 §3.5.2.1 (Add Operation): "If the target location already + # contains the value specified, no changes SHOULD be made to the + # resource, and a success response SHOULD be returned. Unless other + # operations change the resource, this operation SHALL NOT change the + # modify timestamp of the resource." + created = scim_client.create( + User[EnterpriseUser](user_name="noop", display_name="Same") + ) + patch_op = PatchOp[User[EnterpriseUser]]( + operations=[PatchOperation(op="replace", path="displayName", value="Same")] + ) + scim_client.modify(User[EnterpriseUser], created.id, patch_op) + reloaded = scim_client.query(User[EnterpriseUser], created.id) + assert reloaded.meta.last_modified == created.meta.last_modified + + +def test_replace_unknown_resource_returns_404(scim_client): + with pytest.raises(SCIMException) as exc_info: + scim_client.replace( + User[EnterpriseUser](id="does-not-exist", user_name="ghost") + ) + assert exc_info.value.status == 404 + + +def test_per_resource_search_endpoint(scim_client): + created = scim_client.create(User[EnterpriseUser](user_name="searchable")) + scim_client.create(User[EnterpriseUser](user_name="other")) + response = scim_client.search( + SearchRequest[User[EnterpriseUser]](filter='userName eq "searchable"'), + url="/Users/.search", + ) + assert [u.id for u in response.resources] == [created.id] + + +def test_requires_at_least_one_resource_type(): + with pytest.raises(ValueError): + SCIM2(InMemoryStorage(), ScimProvider()) + + +def test_with_meta_sets_location_when_storage_sets_none(make_scim_client): + """`_with_meta` computes `meta.location`, which a storage need not keep.""" + + class BareStorage(InMemoryStorage): + def query(self, resource_type, resource_id): + resource = super().query(resource_type, resource_id) + resource.meta = Meta(resource_type=resource_type.name) + return resource + + storage = BareStorage() + created = storage.create(ResourceType.from_resource(User), User(user_name="bare")) + app = Flask(__name__) + SCIM2(storage, ScimProvider(models=[User]), app=app) + + reloaded = make_scim_client(app).query(User, created.id) + assert reloaded.meta.location == f"http://localhost/scim/v2/Users/{created.id}" + + +def test_resource_types_are_the_ones_the_provider_declares(make_scim_client): + resource_type = ResourceType.from_resource(User) + resource_type.id = resource_type.name = "Account" + resource_type.endpoint = "/Accounts" + app = Flask(__name__) + SCIM2( + InMemoryStorage(), + ScimProvider(models=[User], resource_types=[resource_type]), + app=app, + ) + client = Client(app) + + response = client.post( + "/scim/v2/Accounts", + json={"schemas": [User.__schema__], "userName": "bjensen"}, + content_type="application/scim+json", + ) + assert response.status_code == 201 + assert response.json["meta"]["resourceType"] == "Account" + assert response.json["meta"]["location"].startswith( + "http://localhost/scim/v2/Accounts/" + ) + + response = client.get("/scim/v2/Accounts/unknown") + assert response.status_code == 404 + assert response.json["detail"] == "Account 'unknown' not found" + + +@pytest.mark.parametrize( + ("unknown", "status", "kept"), + [ + (ScimPolicy.Unknown.forbid, 400, False), + (ScimPolicy.Unknown.ignore, 201, False), + (ScimPolicy.Unknown.keep, 201, True), + ], +) +def test_payloads_are_read_under_the_provider_policy(unknown, status, kept): + app = Flask(__name__) + SCIM2( + InMemoryStorage(), + ScimProvider(models=[User], policy=ScimPolicy(unknown=unknown)), + app=app, + ) + + response = Client(app).post( + "/scim/v2/Users", + json={"schemas": [User.__schema__], "userName": "bjensen", "bogus": 1}, + content_type="application/scim+json", + ) + assert response.status_code == status + assert ("bogus" in response.json) is kept + + +def test_resource_types_sharing_a_schema_are_served_apart(): + # RFC7643 §6: a resource type binds a schema to an endpoint, so two + # resource types may share one schema and remain distinct. + users = ResourceType.from_resource(User) + admins = ResourceType.from_resource(User) + admins.id = admins.name = "Admin" + admins.endpoint = "/Admins" + app = Flask(__name__) + SCIM2( + InMemoryStorage(), + ScimProvider(models=[User], resource_types=[users, admins]), + app=app, + ) + client = Client(app) + + for endpoint, user_name in (("Users", "bjensen"), ("Admins", "root")): + response = client.post( + f"/scim/v2/{endpoint}", + json={"schemas": [User.__schema__], "userName": user_name}, + content_type="application/scim+json", + ) + assert response.status_code == 201 + + listed = client.get("/scim/v2/Admins").json + assert [r["userName"] for r in listed["Resources"]] == ["root"] + + response = client.post( + "/scim/v2/.search", + json={"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]}, + content_type="application/scim+json", + ) + assert [ + (r["userName"], r["meta"]["resourceType"], r["meta"]["location"].split("/")[-2]) + for r in response.json["Resources"] + ] == [("bjensen", "User", "Users"), ("root", "Admin", "Admins")] + + +def test_resource_of_another_type_sharing_the_schema_is_not_found(): + users = ResourceType.from_resource(User) + admins = ResourceType.from_resource(User) + admins.id = admins.name = "Admin" + admins.endpoint = "/Admins" + app = Flask(__name__) + SCIM2( + InMemoryStorage(), + ScimProvider(models=[User], resource_types=[users, admins]), + app=app, + ) + client = Client(app) + + admin = client.post( + "/scim/v2/Admins", + json={"schemas": [User.__schema__], "userName": "root"}, + content_type="application/scim+json", + ).json + response = client.get(f"/scim/v2/Users/{admin['id']}") + assert response.status_code == 404 + assert response.json["detail"] == f"User {admin['id']!r} not found" + + +@pytest.mark.parametrize( + ("method", "url", "json"), + [ + ("GET", "/scim/v2/Users", None), + ( + "POST", + "/scim/v2/.search", + {"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"]}, + ), + ], +) +def test_storage_must_name_resource_types(method, url, json): + class UnnamedStorage(InMemoryStorage): + def search(self, resource_types, search_request): + total, resources = super().search(resource_types, search_request) + for resource in resources: + resource.meta.resource_type = None + return total, resources + + app = Flask(__name__) + app.testing = True + SCIM2(UnnamedStorage(), ScimProvider(models=[User]), app=app) + client = Client(app) + client.post( + "/scim/v2/Users", + json={"schemas": [User.__schema__], "userName": "bjensen"}, + content_type="application/scim+json", + ) + + with pytest.raises(ValueError, match="meta.resourceType"): + client.open(url, method=method, json=json, content_type="application/scim+json") + + +def test_user_name_is_unique_across_resource_types_sharing_the_user_schema(): + users = ResourceType.from_resource(User) + admins = ResourceType.from_resource(User) + admins.id = admins.name = "Admin" + admins.endpoint = "/Admins" + app = Flask(__name__) + SCIM2( + InMemoryStorage(), + ScimProvider(models=[User], resource_types=[users, admins]), + app=app, + ) + client = Client(app) + + statuses = [ + client.post( + f"/scim/v2/{endpoint}", + json={"schemas": [User.__schema__], "userName": "bjensen"}, + content_type="application/scim+json", + ).status_code + for endpoint in ("Users", "Admins") + ] + assert statuses == [201, 409] + + +def test_overridden_resource_location_applies_everywhere(): + class CustomSCIM2(SCIM2): + def resource_location(self, resource_type, resource_id): + return f"https://scim.example/{resource_type.endpoint.lstrip('/')}/{resource_id}" + + app = Flask(__name__) + CustomSCIM2(InMemoryStorage(), create_provider(), app=app) + client = Client(app) + + created = client.post( + "/scim/v2/Users", + json={"schemas": [User.__schema__], "userName": "bjensen"}, + content_type="application/scim+json", + ) + location = f"https://scim.example/Users/{created.json['id']}" + assert created.json["meta"]["location"] == location + assert created.headers["Location"] == location + + bulk = client.post( + "/scim/v2/Bulk", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], + "Operations": [ + {"method": "DELETE", "path": f"/Users/{created.json['id']}"}, + {"method": "DELETE", "path": "/Users/unknown"}, + ], + }, + content_type="application/scim+json", + ) + assert [op["location"] for op in bulk.json["Operations"]] == [ + location, + "https://scim.example/Users/unknown", + ] + + +def test_http_exception_headers_are_kept(): + """An HTTP error keeps the headers of its exception, such as ``WWW-Authenticate``. + + RFC7644 §2: "a SCIM service provider SHALL indicate supported HTTP + authentication schemes via the "WWW-Authenticate" header." + """ + + class ProtectedSCIM2(SCIM2): + def create_blueprint(self): + blueprint = super().create_blueprint() + + @blueprint.before_request + def refuse(): + raise Unauthorized( + "Missing or invalid token", + www_authenticate=WWWAuthenticate("Bearer"), + ) + + return blueprint + + app = Flask(__name__) + ProtectedSCIM2(InMemoryStorage(), create_provider(), app=app) + response = Client(app).get("/scim/v2/Users") + assert response.status_code == 401 + assert response.headers["WWW-Authenticate"] == "Bearer" + assert response.headers["Content-Type"] == "application/scim+json" + assert response.json["detail"] == "Missing or invalid token" diff --git a/tests/test_minimal_server.py b/tests/test_minimal_server.py new file mode 100644 index 0000000..ba0fa7f --- /dev/null +++ b/tests/test_minimal_server.py @@ -0,0 +1,107 @@ +import pytest +from scim2_models import EnterpriseUser +from scim2_models import ResourceType +from scim2_models import SearchRequest +from scim2_models import UniquenessException +from scim2_models import User + +from examples.minimal_server import InMemoryStorage +from scim2_flask import ResourceNotFoundError + + +def test_uniqueness_conflict_on_user_name(scim_client): + scim_client.create(User[EnterpriseUser](user_name="dupe")) + with pytest.raises(UniquenessException) as exc_info: + scim_client.create(User[EnterpriseUser](user_name="dupe")) + assert exc_info.value.status == 409 + + +def test_search_filters_and_sorts(scim_client): + scim_client.create( + User[EnterpriseUser](user_name="charlie", emails=[User.Emails(value="c@x.com")]) + ) + scim_client.create( + User[EnterpriseUser]( + user_name="alice", emails=[User.Emails(value="a@x.com", primary=True)] + ) + ) + response = scim_client.query( + User[EnterpriseUser], + query_parameters=SearchRequest(filter="userName pr", sort_by="emails"), + ) + values = [u.emails[0].value for u in response.resources] + assert values == sorted(values) + + +def test_sort_puts_resources_without_a_value_last(scim_client): + scim_client.create( + User[EnterpriseUser]( + user_name="has-email", emails=[User.Emails(value="a@x.com")] + ) + ) + scim_client.create(User[EnterpriseUser](user_name="no-email")) + response = scim_client.query( + User[EnterpriseUser], query_parameters=SearchRequest(sort_by="emails") + ) + assert [u.user_name for u in response.resources] == ["has-email", "no-email"] + + +def test_update_unknown_resource_raises(): + storage = InMemoryStorage() + with pytest.raises(ResourceNotFoundError): + storage.update( + ResourceType.from_resource(User[EnterpriseUser]), + User[EnterpriseUser](id="does-not-exist", user_name="ghost"), + ) + + +def test_tutorial_smoke(client): + """Send the requests of the tutorial, and check the answers it shows.""" + headers = {"Content-Type": "application/scim+json"} + user_payload = { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User", + ], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { + "employeeNumber": "42" + }, + } + + response = client.post("/scim/v2/Users", json=user_payload, headers=headers) + assert response.status_code == 201 + user = response.json + assert user["userName"] == "bjensen" + assert user["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] == { + "employeeNumber": "42" + } + assert user["meta"]["resourceType"] == "User" + assert user["meta"]["location"].endswith(f"/scim/v2/Users/{user['id']}") + + response = client.get(user["meta"]["location"]) + assert response.status_code == 200 + assert response.json == user + + response = client.post("/scim/v2/Users", json=user_payload, headers=headers) + assert response.status_code == 409 + assert response.json["scimType"] == "uniqueness" + assert response.json["detail"] == "userName 'bjensen' is already taken" + + response = client.post( + "/scim/v2/Groups", + json={ + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "displayName": "Engineers", + }, + headers=headers, + ) + assert response.status_code == 201 + group = response.json + assert group["displayName"] == "Engineers" + assert group["meta"]["resourceType"] == "Group" + + response = client.get("/scim/v2/Groups") + assert response.status_code == 200 + assert response.json["totalResults"] == 1 + assert response.json["Resources"] == [group] diff --git a/tests/test_root_search.py b/tests/test_root_search.py new file mode 100644 index 0000000..1b6d168 --- /dev/null +++ b/tests/test_root_search.py @@ -0,0 +1,82 @@ +"""Tests for the POST /.search endpoint at the server root, RFC7644 §3.4.3.""" + +import pytest +from scim2_models import Group +from scim2_models import User + + +@pytest.fixture +def root_search(client): + """Populate users A, B, C and groups B2, Z, and return a root search helper.""" + for name in ("a", "b", "c"): + client.post( + "/scim/v2/Users", + json={ + "schemas": [User.__schema__], + "userName": name, + "displayName": name.upper(), + }, + content_type="application/scim+json", + ) + for name in ("B2", "Z"): + client.post( + "/scim/v2/Groups", + json={"schemas": [Group.__schema__], "displayName": name}, + content_type="application/scim+json", + ) + + def search(**parameters): + response = client.post( + "/scim/v2/.search", + json={ + "schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"], + **parameters, + }, + content_type="application/scim+json", + ) + assert response.status_code == 200 + return response.json + + return search + + +def display_names(response): + return [resource["displayName"] for resource in response["Resources"]] + + +def test_root_search_gathers_every_resource_type(root_search): + # RFC7644 §3.4.3: "Clients MAY execute queries without passing parameters + # on the URL by using the HTTP POST verb combined with the "/.search" + # path extension." + response = root_search() + assert response["totalResults"] == 5 + assert [r["meta"]["resourceType"] for r in response["Resources"]] == [ + "User", + "User", + "User", + "Group", + "Group", + ] + + +def test_root_search_pages_across_resource_types(root_search): + # RFC7644 §3.4.2.4: "count Non-negative integer. Specifies the desired + # maximum number of query results per page" + response = root_search(startIndex=3, count=2) + assert response["totalResults"] == 5 + assert response["startIndex"] == 3 + assert response["itemsPerPage"] == 2 + assert display_names(response) == ["C", "B2"] + + +def test_root_search_sorts_across_resource_types(root_search): + response = root_search(sortBy="displayName", sortOrder="descending") + assert display_names(response) == ["Z", "C", "B2", "B", "A"] + + +def test_root_search_filter_on_an_attribute_of_one_resource_type(root_search): + # RFC7644 §3.4.2.1: "for filtered attributes that are not part of a + # particular resource type, the service provider SHALL treat the + # attribute as if there is no attribute value." + response = root_search(filter='userName eq "a"') + assert display_names(response) == ["A"] diff --git a/tests/test_scim_tester.py b/tests/test_scim_tester.py new file mode 100644 index 0000000..5a09f51 --- /dev/null +++ b/tests/test_scim_tester.py @@ -0,0 +1,39 @@ +"""Validate the example server against the official SCIM conformance tester. + +https://github.com/python-scim/scim2-tester +""" + +import pytest +from scim2_tester import Status +from scim2_tester import check_server + +SUPPORTED_TAGS = [ + "discovery", + "resource-types", + "schemas", + "service-provider-config", + "crud:create", + "crud:read", + "crud:read:attributes", + "crud:update", + "crud:delete", + "patch:add", + "patch:remove", + "patch:replace", + "misc", +] + + +@pytest.mark.parametrize("tag", SUPPORTED_TAGS) +@pytest.mark.parametrize("resource_type", [None, "User", "Group"]) +def test_individual_filters(scim_client, tag, resource_type): + """Test that all SCIM server tests pass or are skipped for each tag and resource type combination.""" + results = check_server( + scim_client, + raise_exceptions=True, + include_tags={tag}, + resource_types=resource_type, + ) + + for result in results: + assert result.status in (Status.SKIPPED, Status.SUCCESS) diff --git a/tests/test_unsupported_features.py b/tests/test_unsupported_features.py new file mode 100644 index 0000000..f1a40f9 --- /dev/null +++ b/tests/test_unsupported_features.py @@ -0,0 +1,118 @@ +"""Features the ServiceProviderConfig does not announce are refused or ignored.""" + +import pytest +from flask import Flask +from scim2_models import Bulk +from scim2_models import BulkOperation +from scim2_models import BulkRequest +from scim2_models import InvalidFilterException +from scim2_models import Patch +from scim2_models import PatchOp +from scim2_models import PatchOperation +from scim2_models import SCIMException +from scim2_models import ScimProvider +from scim2_models import SearchRequest +from scim2_models import ServiceProviderConfig +from scim2_models import User + +from examples.minimal_server import InMemoryStorage +from scim2_flask import SCIM2 + + +@pytest.fixture +def scim_client(make_scim_client): + app = Flask(__name__) + SCIM2(InMemoryStorage(), ScimProvider(models=[User]), app=app) + return make_scim_client(app) + + +def test_listing_without_filter_is_served(scim_client): + created = scim_client.create(User(user_name="bjensen")) + response = scim_client.query(User) + assert [u.id for u in response.resources] == [created.id] + + +def test_unsupported_filter_is_refused_on_resource_endpoint(scim_client): + # RFC7644 §3.4.2.2: "When specified, only those resources matching the + # filter expression SHALL be returned." + with pytest.raises(InvalidFilterException) as exc_info: + scim_client.query( + User, query_parameters=SearchRequest(filter='userName eq "bjensen"') + ) + assert exc_info.value.status == 400 + + +def test_unsupported_filter_is_refused_on_resource_search(scim_client): + with pytest.raises(InvalidFilterException): + scim_client.search( + SearchRequest[User](filter='userName eq "bjensen"'), url="/Users/.search" + ) + + +def test_unsupported_filter_is_refused_on_root_search(scim_client): + with pytest.raises(InvalidFilterException): + scim_client.search(SearchRequest(filter='userName eq "bjensen"')) + + +def test_unsupported_bulk_is_refused(scim_client): + # RFC7644 §3.12, Table 8, "501 (Not Implemented)": "Service provider does + # not support the request operation, e.g., PATCH." + bulk_request = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="POST", bulk_id="u1", path="/Users", data=User(user_name="x") + ) + ] + ) + with pytest.raises(SCIMException) as exc_info: + scim_client.bulk(bulk_request) + assert exc_info.value.status == 501 + assert exc_info.value.detail == "Bulk operations are not supported" + + +@pytest.fixture +def unpatchable_scim_client(make_scim_client): + config = ServiceProviderConfig( + patch=Patch(supported=False), + bulk=Bulk(supported=True, max_operations=10, max_payload_size=1_048_576), + ) + app = Flask(__name__) + SCIM2(InMemoryStorage(), ScimProvider(models=[User], config=config), app=app) + return make_scim_client(app) + + +def test_unsupported_patch_is_refused(unpatchable_scim_client): + # RFC7644 §3.12, Table 8, "501 (Not Implemented)": "Service provider does + # not support the request operation, e.g., PATCH." + created = unpatchable_scim_client.create(User(user_name="bjensen")) + patch_op = PatchOp[User]( + operations=[PatchOperation(op="replace", path="displayName", value="Babs")] + ) + with pytest.raises(SCIMException) as exc_info: + unpatchable_scim_client.modify(User, created.id, patch_op) + assert exc_info.value.status == 501 + assert exc_info.value.detail == "PATCH is not supported" + assert unpatchable_scim_client.query(User, created.id).display_name is None + + +def test_unsupported_patch_is_refused_in_bulk(unpatchable_scim_client): + created = unpatchable_scim_client.create(User(user_name="bjensen")) + bulk_request = BulkRequest[User]( + operations=[ + BulkOperation[User]( + method="PATCH", + path=f"/Users/{created.id}", + data=PatchOp[User]( + operations=[ + PatchOperation(op="replace", path="displayName", value="Babs") + ] + ), + ) + ] + ) + response = unpatchable_scim_client.bulk(bulk_request) + operation = response.operations[0] + assert operation.status == 501 + assert operation.response.detail == "PATCH is not supported" + assert operation.location.endswith(f"/Users/{created.id}") + assert unpatchable_scim_client.query(User, created.id).display_name is None diff --git a/uv.lock b/uv.lock index 1885280..4b6674e 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten'", "python_full_version < '3.12'", ] @@ -36,6 +37,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -45,6 +59,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -201,6 +224,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -210,6 +242,125 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/3a/d09495dfd5191b5593852bbe2f037afae768157443378d066d9ecea69a49/coverage-7.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:72e013665e25cf9d44779f01f340af26319756f9a76822b7c94ce6b1d93813da", size = 223307, upload-time = "2026-09-13T19:08:44.914Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/310196a80258e28ebf6e5aa814a5a6ffbb18c03cf3c0c0c04191ed893e88/coverage-7.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb05c0ff98b56ba6969adf35556bc43bcb8d094df8bc9cb403acff53460c4e07", size = 223817, upload-time = "2026-09-13T19:08:46.738Z" }, + { url = "https://files.pythonhosted.org/packages/73/29/ed0fda1fcd440cdfc07e72a07f3c9c3b43f65a6b3d53bff2cba9b9de50e8/coverage-7.16.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0ececb32090e3fbb03e0d352b973a0485879b4de6c58daf47227b9988b99e5", size = 254222, upload-time = "2026-09-13T19:08:48.358Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4e/5e2507fbd71ec1047620978f39568b0d2ed6c4468d08495a0c24f5677f65/coverage-7.16.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f0ba3892d81aacf36996c52f16bca04e39af31a6c5de930b7688ab617f4a6475", size = 256134, upload-time = "2026-09-13T19:08:49.926Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b1/a9f97846554bc240473656f9c3874591105f29c08eccf4282daadc6bc281/coverage-7.16.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6410b75fe07d5271eaa95fc24bd0a9177ed588d9d1c10c0cf67829adb8f0567", size = 258240, upload-time = "2026-09-13T19:08:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/2b1ab208d06719394851cdd7dc322157870a93b178410b1bda6cac7755f9/coverage-7.16.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1039cb2de093225d597109342ce1675626bd127565e80b3044f4eca07c15b2e", size = 260202, upload-time = "2026-09-13T19:08:53.204Z" }, + { url = "https://files.pythonhosted.org/packages/af/14/ff4db31d6e3126d5b72aab1e868d35ec94e1b71dee46f5c3b54cb1b2438e/coverage-7.16.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cf047bc39fde5425be2628666d0f435ed8817859111c3aacc84b32d858069f5d", size = 254305, upload-time = "2026-09-13T19:08:54.833Z" }, + { url = "https://files.pythonhosted.org/packages/ce/27/c28faa4818f61c49ca3bfb4a77e1c191e79a40447504b89af0bd859cd6fc/coverage-7.16.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c05913d0d5badf7ac83200f35dcf9514cce5df16a7cc89e7d1d7fff0461813b", size = 255935, upload-time = "2026-09-13T19:08:56.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/5a/42e64e5b716048cae33e2e15f8c6fe04a927467056c533b8e88900da895e/coverage-7.16.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4027bf6d7bc0a16df058ce913b69f10c5687f8e1ca668f08caa659ce101744bf", size = 253995, upload-time = "2026-09-13T19:08:58.159Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/860287acd5a1e29acd337f302b476b54148203590091b1a1555b2e914b50/coverage-7.16.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f48b345f831eaf4402ab6333c2dc3e2e2b5bc7b9c1b8fe12680dee3f0538f01", size = 257767, upload-time = "2026-09-13T19:08:59.958Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c5/4b68006da567b4b413352f891776c35776b3557a0479a28187ab57c21659/coverage-7.16.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8643baeb590726c558b2faed6cd59b0917480f9367fcc692026f1a86d824fd08", size = 253715, upload-time = "2026-09-13T19:09:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0e/b2d0c47cfbf11770e197f1d0f11a92864eb29fd7eaca45bc9915573d9725/coverage-7.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d06dcc420b570bf683cdb647cc8fe62b672d9e429ef711c3cbbb7a6880ca1572", size = 254624, upload-time = "2026-09-13T19:09:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/3bab8821b2b942155578fc34e88e21b0670d922f039ab23651f6035ba7f7/coverage-7.16.1-cp311-cp311-win32.whl", hash = "sha256:946f58aa59b08bcd6afcc6a7bd0ff54ed5eee844f32ad69fe6814836d15856a2", size = 225404, upload-time = "2026-09-13T19:09:05.174Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/4f4ce667a97d1c538b46a79b89161395ad2beffc248b6c782afc9f521927/coverage-7.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:684c7ee9b4c04358fe6ac8b517ab51ec35fcd79d08ff0f105dd8bcd96885bbb7", size = 225879, upload-time = "2026-09-13T19:09:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/26/17/f3dce5e47351ab34522dd037fd64b111742958e24861bdd2820971bc1a26/coverage-7.16.1-cp311-cp311-win_arm64.whl", hash = "sha256:1b24f79e25bcf6c73931aeca7a3dfc7595c0cb5e9364aba3fdf387a3de4b1c22", size = 225428, upload-time = "2026-09-13T19:09:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/7cd4c9f2a3b7574414222ec425d5eee21cc690d867a013315e3be8ba185c/coverage-7.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b7f2c26ce6ce0b1e0ca0d5fae96ea510e3a2e78b7207f06e76b7f2c87fa3d0af", size = 223475, upload-time = "2026-09-13T19:09:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f2/9ac65f9cedd43f91e2d3657c11f13aef82aebae89b2243dc9e44fe58a740/coverage-7.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070acb9da788dff743a4d36fc015feee12d68f0349959017542017c79f59c21c", size = 223845, upload-time = "2026-09-13T19:09:11.988Z" }, + { url = "https://files.pythonhosted.org/packages/62/4d/f13db452d4367fe1122abfd98ae330596f65a3b40498f8e1a5811f68f123/coverage-7.16.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e366587b370bc9b8b51b7b7272c610c56db5d5b4795b9e4a29d28ff2f440f809", size = 255341, upload-time = "2026-09-13T19:09:13.708Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6b/85ed86e82835a96ddfbe7705c387c28ce1cbbecd1b6d606f5ddfeeb712df/coverage-7.16.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e82e10b9d290f60b63459cfb245a841aec347603997206296b93881463a93dcf", size = 258078, upload-time = "2026-09-13T19:09:15.368Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d2/f66945853d850b9c05f4e012e37396f1f31d0cd40d062394b229c22e7b56/coverage-7.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d73bb1f85c4150ac208fb0755beb04b2e44897bad81414de9380f98dd74729f", size = 259191, upload-time = "2026-09-13T19:09:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/77/a1/7b907abe62461f289035c7ac60ab3e6334efb8c425151aac4abfa0c97820/coverage-7.16.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3397b9032553d281ad6a9253b12675b65e0cc8cd7a3b0633cf48872c9eb13360", size = 261452, upload-time = "2026-09-13T19:09:18.756Z" }, + { url = "https://files.pythonhosted.org/packages/51/e6/e26f4d6b1069a2a2fee3238a132f85c5b4c1dc25aebd88e015451ff0bd68/coverage-7.16.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77890395cf37026a5907d3ad32376aa51f41c0f163b7477fdbd4f94966cc1d08", size = 255698, upload-time = "2026-09-13T19:09:20.786Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ec/99db7450e813050ebce300e31bbd4f997c76a8c6e3a6d168c5dffc104109/coverage-7.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b0944dc3bee3091039bf970d73caaf930c906013128a421bdc132e797494d941", size = 257111, upload-time = "2026-09-13T19:09:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9d/500df9d3cd8c541ac84b5e0bcab00646be75654e05aa34e8410ec851282d/coverage-7.16.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b89d22a89d5bc05dd95b64e08295b8394aa96dc88e08f8ba210c9ebfebbe0489", size = 255258, upload-time = "2026-09-13T19:09:24.429Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/53318d96da332bb4946f8ac646fa19fd37fecbd0c49144735c716ac69bf0/coverage-7.16.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:550a2a1faf7559f13d5344f12d1eb886ad87955155d7dfab2a3fe5c8ec8fe776", size = 259326, upload-time = "2026-09-13T19:09:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ce/abc0462b2e6ae96ae22197d2bc30fe33b6b4e52937e782745436ceb2a761/coverage-7.16.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:55eb268e5b81aefac759766c9162625b06c1bedb7b77d936225bafc4f038a6f6", size = 254827, upload-time = "2026-09-13T19:09:28.191Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e1/0a04eedaaf19196b51f0180968134228c7646e469ed29914e48690a7cf3e/coverage-7.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65a8fc80898c9ce59f04349fe8b4849b1f9787f14e52ead990e5f849ff4727a0", size = 256698, upload-time = "2026-09-13T19:09:29.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/1d/d441a55cf22ce9d8e9e34814806c47441ab844bd534e0f4b64e1c6ae8bd1/coverage-7.16.1-cp312-cp312-win32.whl", hash = "sha256:528a61be40977c340cf201d23b69bd6a6bab507da60e9dbda85f8b30e935d70d", size = 225541, upload-time = "2026-09-13T19:09:31.752Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/ec3d032375735dd331477caa051419678079ff90fcb53d0284a6c2bfb757/coverage-7.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:d0f02c633630e2b74522108ee95a84ad6e1204a8016a6cca5297f335ea27147e", size = 226075, upload-time = "2026-09-13T19:09:33.556Z" }, + { url = "https://files.pythonhosted.org/packages/94/00/90e9f5c4434878494306b9c0ee8068ebbd829483737d8cefccc58884d728/coverage-7.16.1-cp312-cp312-win_arm64.whl", hash = "sha256:2959978f9d1d20a2c0c15d0a68baaeccf615ac1aa214cf4a05a10d6f568926c8", size = 225461, upload-time = "2026-09-13T19:09:35.48Z" }, + { url = "https://files.pythonhosted.org/packages/aa/74/c08c0c4dc9fa6bcd1d90728a63660aa1b17b488a806948598456c48f75d1/coverage-7.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ee5465db6e9152a7d09f3215309326878c6aa3ac509195a369f9d264ff4bfbd9", size = 223501, upload-time = "2026-09-13T19:09:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c8/784986d326663285258a8e39835c46fb730cc85284f0dbcd82078586dd22/coverage-7.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b8256f8b525ba233d2e4cdcdce0d6673c66fc9bf70df1fd5e67c54a74e2d245", size = 223876, upload-time = "2026-09-13T19:09:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/15/76/73fb792928872bbb07e553f920ff55c65ee962c469265feb5d1d4ae5f97a/coverage-7.16.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57cc400275b9a2892e905fc893f732b21ddb95271bf96406c88e2f6367848b5", size = 254863, upload-time = "2026-09-13T19:09:41.326Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8d/1fd78899513244b065ccecdd1cfc6aa8b8f1bdee796d8c4f2aa8e8e5397d/coverage-7.16.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b3fd0f3435ebb7a7183b32a6062a8b755f08242ced1f3f22761d30b56b3c2a5", size = 257460, upload-time = "2026-09-13T19:09:43.086Z" }, + { url = "https://files.pythonhosted.org/packages/47/1c/b23ddfcb7ef9bd7b3fdb7be9a5dccf9925ae88e556df7aa5b655283c78f2/coverage-7.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5eb1762e7eb5fad34ef913e8107c7788a66f19d328e598ce95bf7217f9e5c8f", size = 258697, upload-time = "2026-09-13T19:09:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c9/19d0c6ca35778a7e9415c30c46a0c64c9d4374219d004a35563132296896/coverage-7.16.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46cd3a73e9140410de62cceb66214bce0e08fb3922b9176fbfc1522fec151b41", size = 260827, upload-time = "2026-09-13T19:09:47.061Z" }, + { url = "https://files.pythonhosted.org/packages/97/fc/5862f7344382c62b85df43d483e579168cc062e007f54d895dfa51fe3e7d/coverage-7.16.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d1ba5142d68dd2cb775cbd0ac8601819298152047803c8efe4eec6d7d7aa7878", size = 255038, upload-time = "2026-09-13T19:09:48.945Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/5c26583199a68df8d15124b4590520903f8eb7cef17db293fea712bb0783/coverage-7.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c389c6f9d1d518e1249ddcb8a7f158135644ce2c508fa6cc17b680777dad5bf2", size = 256827, upload-time = "2026-09-13T19:09:50.738Z" }, + { url = "https://files.pythonhosted.org/packages/95/13/605198bff079b107336710f28a797312ab132587168600d70a290e7ecd2e/coverage-7.16.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cdc57746c7ac0ea063351b4d651c3bb4dd4fd35e64dbb8e90c10e14eb03c4080", size = 254794, upload-time = "2026-09-13T19:09:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/43/b7/0bbb32dc5ccdac766a13763fdfbffd7d73fc80349a69230c46c6b71e7508/coverage-7.16.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5597180ed7670cc94c04c65347418a467d3a43d5f0cf52fcac647f5425f42037", size = 258947, upload-time = "2026-09-13T19:09:54.372Z" }, + { url = "https://files.pythonhosted.org/packages/13/bd/65c31ddd43ff4e61b63721b3dad17cca412dba6e2ce89f92abdf75734339/coverage-7.16.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9647a0ac46255b8fef59a433a2161f03e5483f3a35e1cbd9dfe4600baff0c6b", size = 254613, upload-time = "2026-09-13T19:09:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/d278115f2ba522b3e3128c6852be265f4e0df7202b2effca4db96cf0217d/coverage-7.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e306e98186b9cd109121f3583aeb7978797ad21d948f22944c5c08845cd554d0", size = 256388, upload-time = "2026-09-13T19:09:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ca/d43aa396fb3a2f71c9411b99d926475f5acaf5c124f605c592865c80c8d5/coverage-7.16.1-cp313-cp313-win32.whl", hash = "sha256:48a78a66fcce49d7f6156524bf979c0ac633d584199717c68c6ffa949fc14e6a", size = 225550, upload-time = "2026-09-13T19:09:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/0e/30/df2f6114f6a17cffe94721bd1d298f77f86aad493717fa5bc03cb291a1a6/coverage-7.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af03247d598a353bbbbe1b925deb735276e4d845e7197c4073dc89352b236fa", size = 226091, upload-time = "2026-09-13T19:10:02.331Z" }, + { url = "https://files.pythonhosted.org/packages/ee/31/6f90d8aab72a112492bd50e3b5485b53b772b1f5fa70da0a620e723caae6/coverage-7.16.1-cp313-cp313-win_arm64.whl", hash = "sha256:166adae25b05b04c9a84135912066d9c97482115af38df1a419a38aacc6b6f5d", size = 225481, upload-time = "2026-09-13T19:10:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" }, + { url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" }, + { url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" }, + { url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" }, + { url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" }, + { url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" }, + { url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/ae/16/e11addf5322d98e86307da9e00c94644b97cb72c534c74cda89705bebeef/coverage-7.16.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a337dc2d54c74430cd2febb8ee04f7c508ba8b3b412bf0463f077a66cfc73743", size = 223544, upload-time = "2026-09-13T19:11:09.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/d9/7ccd6484f615961d94b09c75904f87d86375109596069ae3a495a205dbbe/coverage-7.16.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3acd1d78397dead78dd1b011b5fc19cc823c190349acd549e63856dff649c80e", size = 223882, upload-time = "2026-09-13T19:11:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/59/37/2fd78152a557df4a7e4ad78d6851ede90c211838d526e4916c6016f45144/coverage-7.16.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73a32694603a34ad01d7e51a481a4023410d8099e1d0757e067945695c10f0ae", size = 254987, upload-time = "2026-09-13T19:11:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4a/58e2f9b8b13cc422aa7f474a498945ec7f446ef473b5b6bae18d2981f2a9/coverage-7.16.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbbe8265736659a6be2e6042b6a35be13545d14b243cc1d7ecf65f90d788a370", size = 257902, upload-time = "2026-09-13T19:11:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/63f8f6a67c03b86455c726be273b2aa5b57fd1122f22350d141bbb142e91/coverage-7.16.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b0359eb4c62f9993e176bc8f50450fc736a6b90dbcc05bb8584e948812699ae", size = 259522, upload-time = "2026-09-13T19:11:18.206Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3b/1f2261da7483e345fe55de11e41ffc16a543511e37c0912134c0dccc4f6c/coverage-7.16.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:da506e669a8a851b59e122b4b219ea70996a6296f44f3a9348a852526ff961de", size = 261723, upload-time = "2026-09-13T19:11:20.628Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8a/7eb1a361e044b7293f49c921a974b532627ed37f3bef5e7e9f58bcd0d5a2/coverage-7.16.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:64a2a5985d81810ed605ff0dc4ccd6555efcb5700353825532a9a0aea65826e1", size = 255462, upload-time = "2026-09-13T19:11:22.733Z" }, + { url = "https://files.pythonhosted.org/packages/67/66/6d94f7ea87e99c519def5cfa5d4a1ab20247d6b403eb6e6b5b63d6aa5985/coverage-7.16.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:66d70132b69b861805dc1ca46cdd733e54c416890e8f1371d2fd103f70b59c9c", size = 257617, upload-time = "2026-09-13T19:11:24.933Z" }, + { url = "https://files.pythonhosted.org/packages/3d/52/c3de0a3868589a1463a4f1cb0222eca0e5fa9f91da865ccf20d2e19ec19c/coverage-7.16.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:db651a9cf325a542bc2b7b8cc8f1b2bdc6492739bae3103731b2f1c85b96cff6", size = 255495, upload-time = "2026-09-13T19:11:27.158Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a3/ea10e75f20fc1e7be10b166a494826a965d19b1dc9c8a81c511a01153311/coverage-7.16.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:4184e78a4465dcda359fb403172b8951dd220929cb0984c02fabca1742fff06f", size = 259728, upload-time = "2026-09-13T19:11:29.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/96/9d8020d97de46f0390739beda196322586cdd87e1fc357223e6a28bc4135/coverage-7.16.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bc53c3f3adaa939b7a063533ffe0ae1259e7073393c618043a99a6970a87e3df", size = 254904, upload-time = "2026-09-13T19:11:31.86Z" }, + { url = "https://files.pythonhosted.org/packages/5b/21/2ff8867d4560f4f639a82d8fcf13c27eea88a96193daba096027c48bb929/coverage-7.16.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b44308854ef210b9b78df9cdfd4e159513382a859f5ef8464306d14a54c2a040", size = 256828, upload-time = "2026-09-13T19:11:34.078Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4b/feacad51cb5163e3baa13281f8a3098f15d7934cc1f28cfe656557eb4e46/coverage-7.16.1-cp315-cp315-win32.whl", hash = "sha256:dccc142614d3419ed71857deb43f1d757829a4c7fce9994464b71e7e38309827", size = 225722, upload-time = "2026-09-13T19:11:36.314Z" }, + { url = "https://files.pythonhosted.org/packages/39/34/66bbc6ffd3c51fa67604c566920e772c5e3baa57f88dcf7b109c7f18b2cb/coverage-7.16.1-cp315-cp315-win_amd64.whl", hash = "sha256:961fc424e9d5229a99f8f1189942d8e7f4e1519147c3af64842f944aca03914d", size = 226195, upload-time = "2026-09-13T19:11:38.493Z" }, + { url = "https://files.pythonhosted.org/packages/f7/96/4bad920d4caed127c37c136a64b0730fa495517d2eb58809e0d81e6e7dff/coverage-7.16.1-cp315-cp315-win_arm64.whl", hash = "sha256:681a9488c5a234397c4f013da065aa9e53eb7af4c78f1f80c6f15e7208acb855", size = 225625, upload-time = "2026-09-13T19:11:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2b/a88c7906eeb7da4394e932060d7008e153b3a2cd8d11782d82faa03bb7a3/coverage-7.16.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8bb09a2d19b04db1fa0e087a7ca4f12458f7e0e7364cfcd838441d86fb1c61f6", size = 224271, upload-time = "2026-09-13T19:11:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1f/98db59c595680d16f553890deac6d72610e04f192c1964ec9b69cbe790ab/coverage-7.16.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2270a794600b635ca9452ce4c32e2fe81a35f9caa17ffac0eba99f14f275bd4d", size = 224564, upload-time = "2026-09-13T19:11:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/699e236d4cb97ec282b0655afc85acf12349e71a02fff0f1a7d0fb643079/coverage-7.16.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4eff405b545dfcf79cf0d9d3ff750e5c5a887aa066114175193a81d249c5ee6", size = 265423, upload-time = "2026-09-13T19:11:47.666Z" }, + { url = "https://files.pythonhosted.org/packages/96/1b/6eaa21912863b3e3bf23cfe605fb62929bb1a41dc33d2bfa8e92000232bd/coverage-7.16.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ee1d5fc9e3bd6a217906929cc97880239a91d20dae7746f538eb0eefee705ab1", size = 268503, upload-time = "2026-09-13T19:11:50.12Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/4eb46bffa98c28a80559a58c12f17e19308d52b31174af1bd49decf76606/coverage-7.16.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4ec944947de098ad5a1738413f9364689a57067ecbc328e9de37218aa1e5cc1", size = 271059, upload-time = "2026-09-13T19:11:52.363Z" }, + { url = "https://files.pythonhosted.org/packages/ed/d4/17a51ef1f6a084c9abbead522cfbee0fe9d29a593128781e724b7a0795b3/coverage-7.16.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f73ee3956fde2d461c9e2955dd48166e4821fc8587d135e8780fb84da2a098b", size = 272039, upload-time = "2026-09-13T19:11:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/9fdeba8b8aa0848030f45f869ddedc306795a8c6e53e901b8553afbf5415/coverage-7.16.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ec9a4ee989c0d06ad95add0dbfdbb72b00ef53f43431ca0b612384e7878e5de", size = 265869, upload-time = "2026-09-13T19:11:57.423Z" }, + { url = "https://files.pythonhosted.org/packages/95/eb/ab3eb506b2e4dd278440fbbbc7ed18424a86d8a981db0b90fdba66f5d34d/coverage-7.16.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7e5727b2508f817f3126d6c33327dda32fe69d15514badfcd61db8bc4209ecef", size = 268883, upload-time = "2026-09-13T19:11:59.71Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0c/f4edefdce33f01954a74237df5ee83ccef5dcd1165e04abf0adf47543fca/coverage-7.16.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:19a3ea2f364012ef06678118fffdc92442a16bef4a5c8ad4f4019dd8f9ac8876", size = 265359, upload-time = "2026-09-13T19:12:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/df/86/ce5ed885fc7ce2adc9a5971134ace257d0f84f19c3d117da89c179b9662e/coverage-7.16.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:996c2b891b441ec2b39725ee3e8386e2f11b4894b92be225fdfd54a3eeada2c8", size = 270056, upload-time = "2026-09-13T19:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/5dbd5f95070cece25c6df9e6ea4ded2a87dc695ce82a49bcb2116452b342/coverage-7.16.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a125fac1f6b1e88488d208a86b578e1790e3c4937f2e1568d23356141d236220", size = 265498, upload-time = "2026-09-13T19:12:06.811Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ff/254b26f3300f87c7c53ac97da41ae75f0e2ac066aa964bc073c0f3750b38/coverage-7.16.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b10095528b866d322d33d6bf1709b7f8cbf959f12e8cb2ba22fc59c8717866b0", size = 267459, upload-time = "2026-09-13T19:12:09.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/20/bf9958f8ddbbf6a2d39da3d4009f33e0fcf19c2d2fd99715c6494cdc8e8c/coverage-7.16.1-cp315-cp315t-win32.whl", hash = "sha256:531d9be377fdcc05593b974656872eb82e808ebeb42a72515e3aaeb8bb7166f5", size = 226021, upload-time = "2026-09-13T19:12:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/35/8f/12d46948704d9e437c8dca2718e5d54ecc2f85396b64b500c29d8dbe10db/coverage-7.16.1-cp315-cp315t-win_amd64.whl", hash = "sha256:46a88f51770df7c9bc376bd57d3f86cdc7624b8e16ac4b585a655c22b7a1b4db", size = 226853, upload-time = "2026-09-13T19:12:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7f/1ca5fd0601054fce5a3539375b997e67683646e2bf62f60c41e70b529fcc/coverage-7.16.1-cp315-cp315t-win_arm64.whl", hash = "sha256:7580432cbe1e8b762660ae5806f04f869e1c02e519836a43f8094437e561e9f0", size = 226163, upload-time = "2026-09-13T19:12:16.589Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -241,6 +392,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/0d/117a771a2bb91df334b66bf4da14cd02f21aefbcfe53180f336ce55e8f90/httpcore2-2.13.0-py3-none-any.whl", hash = "sha256:35ae5be347aa40467b4a5dc032ac67ebb6d27189fc97e8cebcf99616f6a1bb9e", size = 83162, upload-time = "2026-09-14T14:18:02.529Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a0/e9deef4654132857b5a5dbe4eddd0ac59c2814500e11f2f5044cd81103ee/httpx2-2.13.0.tar.gz", hash = "sha256:81bd07dc67a3701729ef1f777a3c00c915d4539604fdb5afd327f8682f6b7b44", size = 100290, upload-time = "2026-09-14T14:18:05.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/d1/a0c72b0e006df654709fbc366cc5bcb53e5aee13e1e3395152c6dd293376/httpx2-2.13.0-py3-none-any.whl", hash = "sha256:fc12720cedf72faa26cca6b4ca394e05c894e7d7933fc45cafe767960804e49a", size = 95565, upload-time = "2026-09-14T14:18:03.553Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -268,6 +484,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -289,18 +514,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -375,45 +588,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] -[[package]] -name = "mdit-py-plugins" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "myst-parser" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "jinja2" }, - { name = "markdown-it-py" }, - { name = "mdit-py-plugins" }, - { 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/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, -] - [[package]] name = "packaging" version = "26.3" @@ -434,18 +608,18 @@ wheels = [ [[package]] name = "prek" -version = "0.5.2" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/05/4402641f168dd862d27592cbd4c5fb171d119c15611e5dbef3df857e8932/prek-0.5.2.tar.gz", hash = "sha256:a90ea4eeba35c6081bad29775f6a49fcc33fb80372424fce64c7a9c5774e8204", size = 549442, upload-time = "2026-09-02T17:49:27.241Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/93/d1e5afc996b9fde04d71c37fd28bdd404e54daf1da6c76b883c5cdbdb411/prek-0.5.3.tar.gz", hash = "sha256:06d88bed9a5b2886cd3796957e4cecf05fa9b06724b625df31dbf0a48ff2330c", size = 551929, upload-time = "2026-09-13T08:38:37.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/25/cf9c6064fea82ba6f4801dc3be650cc86eae3d9951e2a6fb28b21cbfa156/prek-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:40d488a48de3f47f48bd4911ee5dec25e615f992b4cde821e9b50d40c615b39e", size = 5982622, upload-time = "2026-09-02T17:49:10.111Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d9/9772fd4d4023c34780d957f407d9437e2a16d6bef77e3da9fa4374cd75bb/prek-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0eb3b812fc13bc5c05cfc1a8b469f9665a02f845753c3759ad7aca50af707afb", size = 5525662, upload-time = "2026-09-02T17:49:12.336Z" }, - { url = "https://files.pythonhosted.org/packages/3b/19/be25a56ab243f2e5c9333c38bad33bddffec92ea8a25e34248eba6e00ac8/prek-0.5.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0219d1e694a962e3b3ae98f42f53065d2e2856719caee88578d24b35ea74cad", size = 5826109, upload-time = "2026-09-02T17:49:13.994Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bd/a4a5a3022915b8e1768df9b2d8744f3e7c4b3bb7cdbe697612cc96054169/prek-0.5.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e22f11c1fb5df89acf5cbde78f6139edf2c30a205bea5a5d4a094366dcdbda", size = 6201473, upload-time = "2026-09-02T17:49:16.156Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fb/2e5f8adbee718f15333a113872104c4b3d25ecce423dd7117cfcd28a25fe/prek-0.5.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1d52de8428d5eef7c8c0fd64efd7d31210d13924fd36a02975d462675715aeb7", size = 5829166, upload-time = "2026-09-02T17:49:17.997Z" }, - { url = "https://files.pythonhosted.org/packages/44/71/c798d54741cc8f345b211e453dda9599546fb213c3593eb711cb6c710bbf/prek-0.5.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:86aebcd8ab831036fc3ac9b15275412403e21041da031e6c9f1547888843e5c6", size = 6318980, upload-time = "2026-09-02T17:49:21.145Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f8/4aefbf76dae091078655fbaf065c34fa900d43b39c2b88e80cbaa71b5efb/prek-0.5.2-py3-none-win_amd64.whl", hash = "sha256:b6c0e4f272f9410f8218d1db0cc7ea10cd8f62db19f820b9ace9f6c2b33bd8dd", size = 5727075, upload-time = "2026-09-02T17:49:23.656Z" }, - { url = "https://files.pythonhosted.org/packages/df/7c/409ced8866197da50e9bcb2c9bf101e3e9a35b98a851c0ccd2a44509eaa5/prek-0.5.2-py3-none-win_arm64.whl", hash = "sha256:0ebea4ef39f6446f6c414a5a506cde8942b17b5899f74cf90e352628085d4005", size = 5489643, upload-time = "2026-09-02T17:49:25.778Z" }, + { url = "https://files.pythonhosted.org/packages/db/5d/8731dc49cb5424d37424db3ea2f3ff6a0b20caa971db72796e822ce48b24/prek-0.5.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d7240c5e6a996ef5bebe5d57d43049803e41c225b343681c020bf0c87dab2b2", size = 6003518, upload-time = "2026-09-13T08:38:20.584Z" }, + { url = "https://files.pythonhosted.org/packages/51/45/ca413aefb3ea2411bbf0adb6eebfac1e9bfc5ac7679dbe26336fc651c347/prek-0.5.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1dd6df8235ca361dbbeec89c305ccb41a138088a2fc548484b4cb89f66a80d20", size = 5538754, upload-time = "2026-09-13T08:38:23.004Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/874d58ba40d2fe7cfec2789ae8bf415f3751666ad5c59928821fe53c3c01/prek-0.5.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:61ff791bb850ba52cc0d86576498df7e1bd1e9ed20df63dae113a4a6aa495e19", size = 5841568, upload-time = "2026-09-13T08:38:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/8f/40/167037b8eef75f6f036c60ff7e93f42defebb7c3733677e2f30e5a479257/prek-0.5.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a863379a2668c4ef079d820ffd5b14073145380dfbe0e70fef498dc73c568f86", size = 6226960, upload-time = "2026-09-13T08:38:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/42/9a/bfcd3c1fe1fdefee8dc144a92c4b0b93b59f31c7df60ee1720c49223a90a/prek-0.5.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:086d1b77557f0a089568dc5b93c22ccd793351524f3eeb29d36781421a350e72", size = 5854832, upload-time = "2026-09-13T08:38:28.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/1450b995b18bbf0d8ab65016860198052baad5c79c231151fa1dcca4a2de/prek-0.5.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4f09b947255124e9591a7f7ef16eb5340a36ba94a275d788d7ea804fab35f0e2", size = 6343055, upload-time = "2026-09-13T08:38:30.96Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e8/8f45950b8a8ddcf22efc7183e9d6b1d7e30373229ff7d9e56f918a55922c/prek-0.5.3-py3-none-win_amd64.whl", hash = "sha256:5b74a9742c3e8f8688d5dad1439688f112e2e1746fcb91016015dae38df3eb42", size = 5744293, upload-time = "2026-09-13T08:38:33.104Z" }, + { 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]] @@ -608,58 +782,17 @@ wheels = [ ] [[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" }, +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -686,53 +819,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] +[[package]] +name = "scim2-client" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scim2-models" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/68/117e758b4ca42a203ff16b013f0f31b96fd7b1989c6f327344dc9563585e/scim2_client-0.10.0.tar.gz", hash = "sha256:d4d02af0575f64c1f22b82bb3e1d170d9b5fe3eeeabe34847ad24a8f7f1a2276", size = 26291, upload-time = "2026-09-27T20:02:01.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/98/a7d54024f3b5f6747e3fd08c4d6153d90110c295af1b30514d832af40202/scim2_client-0.10.0-py3-none-any.whl", hash = "sha256:cbb3136df1b68770832590338df9c92e9c003ae3bef893f2aa7adcc09ba5c275", size = 27875, upload-time = "2026-09-27T20:02:00.707Z" }, +] + [[package]] name = "scim2-flask" version = "0.0.0" source = { editable = "." } dependencies = [ + { name = "flask" }, { name = "scim2-models" }, ] [package.dev-dependencies] dev = [ + { name = "httpx2" }, { name = "prek" }, { name = "pytest" }, + { name = "pytest-cov" }, + { name = "scim2-tester" }, ] doc = [ - { name = "myst-parser" }, { name = "shibuya" }, { 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'" }, { name = "sphinx-issues" }, + { name = "sphinx-paramlinks" }, + { name = "sphinx-reredirects" }, ] [package.metadata] -requires-dist = [{ name = "scim2-models", specifier = ">=0.6.9" }] +requires-dist = [ + { name = "flask", specifier = ">=3.1.3" }, + { name = "scim2-models", specifier = ">=0.9.0" }, +] [package.metadata.requires-dev] dev = [ - { name = "prek", specifier = ">=0.1.0" }, - { name = "pytest", specifier = ">=8.2.1" }, + { name = "httpx2", specifier = ">=2.13.0" }, + { name = "prek", specifier = ">=0.5.3" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "scim2-tester", specifier = ">=0.4.0" }, ] doc = [ - { name = "myst-parser", specifier = ">=3.0.1" }, - { name = "shibuya", specifier = ">=2024.5.15" }, - { name = "sphinx", specifier = ">=7.3.7" }, - { name = "sphinx-issues", specifier = ">=5.0.0" }, + { name = "shibuya", specifier = ">=2026.7.12" }, + { name = "sphinx", specifier = ">=9.0.4" }, + { name = "sphinx-issues", specifier = ">=6.0.0" }, + { name = "sphinx-paramlinks", specifier = ">=0.6.0" }, + { name = "sphinx-reredirects", specifier = ">=1.1.0" }, ] [[package]] name = "scim2-models" -version = "0.8.2" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lark" }, { name = "pydantic", extra = ["email"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/61/bf0746a7ab542c937b3888375f4df8857824a37833df7a5c3eb5ea7d0bd0/scim2_models-0.8.2.tar.gz", hash = "sha256:8ee4b69687d2680c06827af24a50c93a1d33fbfe0be492e0b2db34f4c5233ecd", size = 94332, upload-time = "2026-09-25T19:07:50.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/25/a7722642f8420ecc0f9c4c271901b43208c263d64b1d7c0a1ed76cdfa661/scim2_models-0.9.0.tar.gz", hash = "sha256:a0b35178a2c03ca0b59300ce3c7724857fb9049dbf7f3b106b6696d32d0ae3cb", size = 96051, upload-time = "2026-09-27T20:00:15.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/f9/59cf15d896064e50e1a7b2f7570c4bc5fbe1071816d29f959456487ad617/scim2_models-0.9.0-py3-none-any.whl", hash = "sha256:934d11e7386edd2e0a682dfb279915c74d9f7b3027daf5333794a30bb7f813eb", size = 118555, upload-time = "2026-09-27T20:00:13.633Z" }, +] + +[[package]] +name = "scim2-tester" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scim2-client" }, + { name = "scim2-models" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/58/25d2aaf0ff298be66b8ddb1d0a11386904df9b5ed10d4c9648d17af1178a/scim2_tester-0.5.0.tar.gz", hash = "sha256:574e2ffc8340758f95fcf4f2bfa56e3bf3d22fab1da6dec8ede48199940c482d", size = 26789, upload-time = "2026-09-27T20:02:43.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/8e/6a793d8f2e2cb7f1f180f277b47ca104dbf87a14b8d53abd4e7f3ae37db9/scim2_models-0.8.2-py3-none-any.whl", hash = "sha256:b27eec4847cfdc57e173f78e81004932e7d7298e3192d05f5fe1ec579f4735e9", size = 116865, upload-time = "2026-09-25T19:07:49.035Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6b/b1542ff8c7fe43c480e14c69dcf5f7c205bf69a815c05b52661a2080375f/scim2_tester-0.5.0-py3-none-any.whl", hash = "sha256:3db48d9ffb2e2c737524d2c5985cba093060873b07ad038dda78660b43d1ad7c", size = 38830, upload-time = "2026-09-27T20:02:42.625Z" }, ] [[package]] @@ -794,7 +964,8 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten'", ] dependencies = [ { name = "alabaster" }, @@ -833,6 +1004,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/8a/28333d222ac31539aa0d818db0790077e5a6a408f933b4bf64f7d3440ffc/sphinx_issues-6.0.0-py3-none-any.whl", hash = "sha256:c7ed2915059526d02022733985a7712d4b2b64a707e16b98294ed9758e64df4f", size = 8362, upload-time = "2026-03-13T17:23:31.031Z" }, ] +[[package]] +name = "sphinx-paramlinks" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { 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/ae/21/62d3a58ff7bd02bbb9245a63d1f0d2e0455522a11a78951d16088569fca8/sphinx-paramlinks-0.6.0.tar.gz", hash = "sha256:746a0816860aa3fff5d8d746efcbec4deead421f152687411db1d613d29f915e", size = 12363, upload-time = "2023-08-11T16:09:28.604Z" } + +[[package]] +name = "sphinx-reredirects" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/1b/8d/0e39fe2740d7d71417edf9a6424aa80ca2c27c17fc21282cdc39f90d5a40/sphinx_reredirects-1.1.0.tar.gz", hash = "sha256:fb9b195335ab14b43f8273287d0c7eeb637ba6c56c66581c11b47202f6718b29", size = 614624, upload-time = "2025-12-22T08:28:02.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/81/b5dd07067f3daac6d23687ec737b2d593740671ebcd145830c8f92d381c5/sphinx_reredirects-1.1.0-py3-none-any.whl", hash = "sha256:4b5692273c72cd2d4d917f4c6f87d5919e4d6114a752d4be033f7f5f6310efd9", size = 6351, upload-time = "2025-12-22T08:27:59.724Z" }, +] + [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" @@ -887,6 +1082,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -916,3 +1174,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From f5b639ceee49de775bf7997e6a52cab45a833bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Thu, 1 Oct 2026 11:23:37 +0200 Subject: [PATCH 2/7] doc: simplify minimal_server docstrings --- examples/minimal_server.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/examples/minimal_server.py b/examples/minimal_server.py index 5bce508..cbf83a4 100644 --- a/examples/minimal_server.py +++ b/examples/minimal_server.py @@ -41,13 +41,7 @@ def make_etag(resource: Resource[Any]) -> str: - """Compute a weak ETag from a resource's content. - - :rfc:`RFC7644 §3.14 <7644#section-3.14>`: "Service providers MAY support - weak ETags as the preferred mechanism for performing conditional - retrievals and ensuring that clients do not inadvertently overwrite each - other's changes, respectively." - """ + """Compute a weak ETag from a resource's content.""" content = resource.model_dump( mode="json", exclude={"meta": {"version", "location"}}, scim_ctx=None ) @@ -56,11 +50,7 @@ def make_etag(resource: Resource[Any]) -> str: class InMemoryStorage(ScimStorage): - """A :class:`ScimStorage` storing resources in a plain dict per resource type name. - - A real deployment would replace this with a SQL, LDAP, or any other - storage backend. - """ + """Keeps resources in memory; a real deployment would use SQL, LDAP, or another backend.""" def __init__(self) -> None: self.resources: dict[str, dict[str, Resource[Any]]] = defaultdict(dict) @@ -122,7 +112,6 @@ def update( return resource def _check_user_name_unique(self, resource: Resource[Any]) -> None: - """Enforce the ``Uniqueness.global_`` scim2-models declares on ``User.userName``.""" user_name = getattr(resource, "user_name", None) if user_name is None: return @@ -147,7 +136,6 @@ def delete(self, resource_type: ResourceType, resource_id: str) -> None: def create_provider() -> ScimProvider: - """Describe the resources served and the capabilities of :class:`InMemoryStorage`.""" return ScimProvider( models=[User, EnterpriseUser, Group], resource_types=[ From 7365f5a696ca1e8f11edf484157a1f93e8c3b5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Thu, 1 Oct 2026 12:22:56 +0200 Subject: [PATCH 3/7] doc: update tutorial, now using scim2-cli as well as curl for examples --- doc/conf.py | 1 + doc/tutorial.rst | 127 +++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 1 + uv.lock | 99 ++++++++++++++++++++---------------- 4 files changed, 159 insertions(+), 69 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 3d77739..81309b1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -9,6 +9,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.todo", "sphinx.ext.viewcode", + "sphinx_design", "sphinx_issues", "sphinx_paramlinks", "sphinx_reredirects", diff --git a/doc/tutorial.rst b/doc/tutorial.rst index c7c1dd9..acf6228 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -2,9 +2,15 @@ Tutorial ======== In this tutorial, we will build a SCIM server that stores users and groups in memory. We will -then create and read resources with ``curl``, as a SCIM client would. +then create and read resources with ``curl`` or with +`scim2-cli `_, as a SCIM client would. -We need Python 3.11 or later, and ``curl``. +We need Python 3.11 or later, and ``curl``. Install scim2-cli too if you want to follow the +scim2-cli examples along: + +.. code-block:: shell + + pip install scim2-cli Install scim2-flask ------------------- @@ -122,14 +128,29 @@ Create a user Send a user to the ``/Users`` endpoint: -.. code-block:: shell +.. tab-set:: + :class: outline + + .. tab-item:: scim2-cli + :sync: scim2-cli + + .. code-block:: shell + + scim2 --url http://localhost:5000/scim/v2 create user \ + --user-name bjensen \ + --enterpriseuser '{"employeeNumber": "42"}' + + .. tab-item:: curl + :sync: curl + + .. code-block:: shell - curl -X POST http://localhost:5000/scim/v2/Users \ - -H "Content-Type: application/scim+json" \ - -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], - "userName": "bjensen", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' + curl -X POST http://localhost:5000/scim/v2/Users \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' The server answers with the user it stored: @@ -161,11 +182,24 @@ from the ones above. Read the user back ------------------ -Copy the ``location`` from the output, and request it: +Copy the ``id`` from the output (or the ``location``, for curl), and request it: -.. code-block:: shell +.. tab-set:: + :class: outline + + .. tab-item:: scim2-cli + :sync: scim2-cli + + .. code-block:: shell + + scim2 --url http://localhost:5000/scim/v2 query user fc4cd4d3-3667-4872-9d98-11ca9ad19303 - curl http://localhost:5000/scim/v2/Users/fc4cd4d3-3667-4872-9d98-11ca9ad19303 + .. tab-item:: curl + :sync: curl + + .. code-block:: shell + + curl http://localhost:5000/scim/v2/Users/fc4cd4d3-3667-4872-9d98-11ca9ad19303 The server answers with the same user: @@ -195,14 +229,29 @@ Create the user again Send the same user a second time: -.. code-block:: shell +.. tab-set:: + :class: outline + + .. tab-item:: scim2-cli + :sync: scim2-cli - curl -X POST http://localhost:5000/scim/v2/Users \ - -H "Content-Type: application/scim+json" \ - -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], - "userName": "bjensen", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' + .. code-block:: shell + + scim2 --url http://localhost:5000/scim/v2 create user \ + --user-name bjensen \ + --enterpriseuser '{"employeeNumber": "42"}' + + .. tab-item:: curl + :sync: curl + + .. code-block:: shell + + curl -X POST http://localhost:5000/scim/v2/Users \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"], + "userName": "bjensen", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"employeeNumber": "42"}}' The storage refuses it, since another user already has this ``userName``: @@ -222,11 +271,24 @@ Create a group Send a group to the ``/Groups`` endpoint: -.. code-block:: shell +.. tab-set:: + :class: outline + + .. tab-item:: scim2-cli + :sync: scim2-cli + + .. code-block:: shell + + scim2 --url http://localhost:5000/scim/v2 create group --display-name Engineers + + .. tab-item:: curl + :sync: curl - curl -X POST http://localhost:5000/scim/v2/Groups \ - -H "Content-Type: application/scim+json" \ - -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Engineers"}' + .. code-block:: shell + + curl -X POST http://localhost:5000/scim/v2/Groups \ + -H "Content-Type: application/scim+json" \ + -d '{"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], "displayName": "Engineers"}' The server answers with the group it stored: @@ -252,9 +314,22 @@ List the groups Request the ``/Groups`` endpoint: -.. code-block:: shell +.. tab-set:: + :class: outline + + .. tab-item:: scim2-cli + :sync: scim2-cli + + .. code-block:: shell - curl http://localhost:5000/scim/v2/Groups + scim2 --url http://localhost:5000/scim/v2 query group + + .. tab-item:: curl + :sync: curl + + .. code-block:: shell + + curl http://localhost:5000/scim/v2/Groups The server answers with a list holding the group we created: @@ -285,8 +360,6 @@ The server answers with a list holding the group we created: "totalResults": 1 } -Notice that ``/Groups`` lists only the group: each resource type has its own collection. - What we built ------------- diff --git a/pyproject.toml b/pyproject.toml index 74d4f51..85d5421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ doc = [ "shibuya>=2026.7.12", "sphinx>=9.0.4", + "sphinx-design>=0.6.1", "sphinx-issues>=6.0.0", "sphinx-paramlinks>=0.6.0", "sphinx-reredirects>=1.1.0", diff --git a/uv.lock b/uv.lock index 4b6674e..08eb27c 100644 --- a/uv.lock +++ b/uv.lock @@ -8,16 +8,16 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-09-17T10:07:53.587050742Z" exclude-newer-span = "P14D" [options.exclude-newer-package] -pytest-scim2-server = false +scim2-server = false +scim2-models = false scim2-cli = false scim2-client = false -scim2-models = false -scim2-server = false scim2-tester = false +pytest-scim2-server = false [[package]] name = "alabaster" @@ -42,8 +42,8 @@ name = "anyio" version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.15'" }, + { name = "idna", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.15' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ @@ -423,8 +423,8 @@ name = "httpcore2" version = "2.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/8c/e925b1c92018abb3a1863ce1549d76d2381e334d21d65d4ac8f65dabd78a/httpcore2-2.13.0.tar.gz", hash = "sha256:2adc8be4fb285fbcd6d894298db3b52c177e74b6674eda3a76bd36be3292a3db", size = 67740, upload-time = "2026-09-14T14:18:04.717Z" } wheels = [ @@ -852,6 +852,7 @@ doc = [ { name = "shibuya" }, { 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'" }, + { name = "sphinx-design" }, { name = "sphinx-issues" }, { name = "sphinx-paramlinks" }, { name = "sphinx-reredirects" }, @@ -874,6 +875,7 @@ dev = [ doc = [ { name = "shibuya", specifier = ">=2026.7.12" }, { name = "sphinx", specifier = ">=9.0.4" }, + { name = "sphinx-design", specifier = ">=0.6.1" }, { name = "sphinx-issues", specifier = ">=6.0.0" }, { name = "sphinx-paramlinks", specifier = ">=0.6.0" }, { name = "sphinx-reredirects", specifier = ">=1.1.0" }, @@ -936,23 +938,23 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -968,29 +970,42 @@ resolution-markers = [ "python_full_version >= '3.12' and sys_platform != 'emscripten'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] +[[package]] +name = "sphinx-design" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/13/7b/804f311da4663a4aecc6cf7abd83443f3d4ded970826d0c958edc77d4527/sphinx_design-0.7.0.tar.gz", hash = "sha256:d2a3f5b19c24b916adb52f97c5f00efab4009ca337812001109084a740ec9b7a", size = 2203582, upload-time = "2026-01-19T13:12:53.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" }, +] + [[package]] name = "sphinx-issues" version = "6.0.0" From 925fd010c317d7ac13fc1c959b45610f3fce6808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Thu, 1 Oct 2026 13:08:33 +0200 Subject: [PATCH 4/7] doc: illustrate 'protect the endpoints' section with authlib example --- doc/conf.py | 1 + doc/how-to/protect-the-endpoints.rst | 57 +++++++- pyproject.toml | 1 + uv.lock | 192 ++++++++++++++++++++++++++- 4 files changed, 248 insertions(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 81309b1..639013d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -30,6 +30,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), + "authlib": ("https://docs.authlib.org/en/stable/", None), "scim2_models": ("https://scim2-models.readthedocs.io/en/latest/", None), "scim2_client": ("https://scim2-client.readthedocs.io/en/latest/", None), "scim2_tester": ("https://scim2-tester.readthedocs.io/en/latest/", None), diff --git a/doc/how-to/protect-the-endpoints.rst b/doc/how-to/protect-the-endpoints.rst index c6eae7b..bdc6bf7 100644 --- a/doc/how-to/protect-the-endpoints.rst +++ b/doc/how-to/protect-the-endpoints.rst @@ -2,9 +2,10 @@ Protect the endpoints ===================== Use this guide to restrict the SCIM endpoints to authenticated clients. The extension leaves -every endpoint open, and lets the application check the credentials. +every endpoint open, and lets the application check the credentials. Write the check yourself, or +delegate it to a library such as :ref:`Authlib `. -Check the credentials +Write your own check --------------------- Subclass :class:`~scim2_flask.SCIM2`, and add a ``before_request`` hook to the blueprint it @@ -51,6 +52,58 @@ SHALL indicate supported HTTP authentication schemes via the "WWW-Authenticate" >>> client.get("/scim/v2/Users", headers={"Authorization": "Bearer secret"}).status_code 200 +.. _authlib-check: + +Validate OAuth 2.0 tokens with Authlib +--------------------------------------- + +`Authlib `_ validates OAuth 2.0 bearer tokens for you, instead of the +hand-written check above. Install it with ``pip install authlib``. + +scim2-flask generates its views itself, so check the token from the ``before_request`` hook, with +a ``with require_oauth.acquire():`` statement, to protect every SCIM endpoint with a single check. +See Authlib's own :ref:`guide on protecting resources ` +for more. + +Register a :class:`~authlib.oauth2.rfc6750.BearerTokenValidator` that looks up tokens in your +storage: + +.. doctest:: + + >>> from dataclasses import dataclass + >>> from authlib.integrations.flask_oauth2 import ResourceProtector + >>> from authlib.oauth2.rfc6750 import BearerTokenValidator + >>> @dataclass + ... class Token: + ... scope: str = "" + ... def is_expired(self): + ... return False + ... def is_revoked(self): + ... return False + ... def get_scope(self): + ... return self.scope + >>> TOKENS = {"secret": Token()} + >>> class MyBearerTokenValidator(BearerTokenValidator): + ... def authenticate_token(self, token_string): + ... return TOKENS.get(token_string) + >>> require_oauth = ResourceProtector() + >>> require_oauth.register_token_validator(MyBearerTokenValidator()) + >>> def check_oauth_token(): + ... with require_oauth.acquire(): + ... pass + >>> class OAuthProtectedSCIM2(SCIM2): + ... def create_blueprint(self): + ... blueprint = super().create_blueprint() + ... blueprint.before_request(check_oauth_token) + ... return blueprint + >>> app = Flask(__name__) + >>> scim2 = OAuthProtectedSCIM2(InMemoryStorage(), create_provider(), app=app) + +Replace ``Token`` and ``authenticate_token`` with your real token storage; Authlib's own +:ref:`authlib:flask_oauth2_server` guide describes how to issue the tokens checked here. The +validated token is then available as +:data:`~authlib.integrations.flask_oauth2.current_token` for the rest of the request. + Announce the scheme ------------------- diff --git a/pyproject.toml b/pyproject.toml index 85d5421..840733d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ funding = "https://github.com/sponsors/python-scim" [dependency-groups] dev = [ + "authlib>=1.8.0", "httpx2>=2.13.0", "prek>=0.5.3", "pytest>=9.1.1", diff --git a/uv.lock b/uv.lock index 08eb27c..9ec7015 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-09-17T10:07:53.587050742Z" +exclude-newer = "2026-09-17T10:31:00.469495689Z" exclude-newer-span = "P14D" [options.exclude-newer-package] @@ -50,6 +50,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] +[[package]] +name = "authlib" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -77,6 +90,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" @@ -361,6 +472,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 = "dnspython" version = "2.8.0" @@ -505,6 +672,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, +] + [[package]] name = "lark" version = "1.3.1" @@ -622,6 +801,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" @@ -842,6 +1030,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "authlib" }, { name = "httpx2" }, { name = "prek" }, { name = "pytest" }, @@ -866,6 +1055,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "authlib", specifier = ">=1.8.0" }, { name = "httpx2", specifier = ">=2.13.0" }, { name = "prek", specifier = ">=0.5.3" }, { name = "pytest", specifier = ">=9.1.1" }, From c747bbabf1967814812df43803036ff5402e182c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Thu, 1 Oct 2026 13:34:56 +0200 Subject: [PATCH 5/7] refactor: make error handling methods private and simplify _handle_http_exception --- src/scim2_flask/extension.py | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/scim2_flask/extension.py b/src/scim2_flask/extension.py index a17296e..defa27a 100644 --- a/src/scim2_flask/extension.py +++ b/src/scim2_flask/extension.py @@ -146,9 +146,9 @@ def _set_content_type(response: Response) -> Response: response.headers["Content-Type"] = "application/scim+json" return response - blueprint.register_error_handler(ValidationError, self.handle_validation_error) - blueprint.register_error_handler(SCIMException, self.handle_scim_exception) - blueprint.register_error_handler(HTTPException, self.handle_http_exception) + blueprint.register_error_handler(ValidationError, self._handle_validation_error) + blueprint.register_error_handler(SCIMException, self._handle_scim_exception) + blueprint.register_error_handler(HTTPException, self._handle_http_exception) for resource_type in self.provider.resource_types: self._register_resource_routes(blueprint, resource_type) @@ -207,37 +207,29 @@ def resource_location(self, resource_type: ResourceType, resource_id: str) -> st slug = self._slug(resource_type) return url_for(f"scim2.get_{slug}", resource_id=resource_id, _external=True) - def handle_validation_error(self, error: ValidationError) -> tuple[dict, int]: + # -- Error responses --------------------------------------------- + + def _handle_validation_error(self, error: ValidationError) -> tuple[dict, int]: """Turn an invalid payload into a SCIM error response. - The response reports only the first validation error. + The response reports only the first validation error: the SCIM + Error resource (RFC7644 §3.12) carries a single detail and + scimType, not a list of errors. """ scim_error = Error.from_validation_error(error.errors()[0]) return scim_error.model_dump(), scim_error.status - def handle_scim_exception(self, error: SCIMException) -> tuple[dict, int]: - """Turn a :class:`~scim2_models.SCIMException` into a SCIM error response.""" + def _handle_scim_exception(self, error: SCIMException) -> tuple[dict, int]: + """Turn a SCIMException into a SCIM error response.""" scim_error = error.to_error() return scim_error.model_dump(), scim_error.status - def handle_http_exception( + def _handle_http_exception( self, error: HTTPException ) -> tuple[dict, int, list[tuple[str, str]]]: - """Turn a Werkzeug :class:`~werkzeug.exceptions.HTTPException` into a SCIM error response. - - The response keeps the headers of the exception, such as the - ``WWW-Authenticate`` of an :class:`~werkzeug.exceptions.Unauthorized`. - :rfc:`RFC7644 §2 <7644#section-2>`: "As per Section 4.1 of - [RFC7235], a SCIM service provider SHALL indicate supported HTTP - authentication schemes via the "WWW-Authenticate" header." - """ + """Turn a Werkzeug HTTPException into a SCIM error response.""" scim_error = Error(status=error.code, detail=error.description) - headers = [ - (name, value) - for name, value in error.get_headers() - if name.lower() != "content-type" - ] - return scim_error.model_dump(), error.code or 500, headers + return scim_error.model_dump(), error.code or 500, error.get_headers() # -- Resource types ---------------------------------------------- From ceedcdeecd228dcfa3fe55527d852e108a2ba808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 2 Oct 2026 14:43:39 +0200 Subject: [PATCH 6/7] doc: remove sphinx syntax from private methods --- src/scim2_flask/extension.py | 57 +++++++++++++++--------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/src/scim2_flask/extension.py b/src/scim2_flask/extension.py index defa27a..c48f6b1 100644 --- a/src/scim2_flask/extension.py +++ b/src/scim2_flask/extension.py @@ -234,7 +234,7 @@ def _handle_http_exception( # -- Resource types ---------------------------------------------- def _model(self, resource_type: ResourceType) -> type[Resource[Any]]: - """Return the model validating the resources of ``resource_type``.""" + """Return the model validating the resources of `resource_type`.""" return cast(type[Resource[Any]], self.provider.model_for(resource_type)) def _endpoint(self, resource_type: ResourceType) -> str: @@ -251,7 +251,7 @@ def _resource_union(self) -> Any: def _resource_type_of(self, resource: Resource[Any]) -> ResourceType: """Return the resource type a resource from the storage belongs to. - The storage records it in ``meta.resourceType``, since two resource + The storage records it in `meta.resourceType`, since two resource types may share a model. """ name = resource.meta.resource_type if resource.meta else None @@ -264,7 +264,6 @@ def _resource_type_of(self, resource: Resource[Any]) -> ResourceType: ) def _resource_type_at(self, endpoint: str) -> ResourceType | None: - """Return the resource type served at ``endpoint``, if any.""" key = endpoint.lstrip("/").casefold() for resource_type in self.provider.resource_types: if self._endpoint(resource_type).casefold() == key: @@ -417,7 +416,7 @@ def delete_view(resource_id: str) -> Any: def _register_discovery_routes(self, blueprint: Blueprint) -> None: def _reject_filter() -> None: - """:rfc:`RFC7644 §4 <7644#section-4>`. + """RFC7644 §4. "Query parameters described in Section 3.4.2, such as filtering, sorting, and pagination, SHALL be ignored. If a @@ -569,11 +568,10 @@ def bulk() -> Any: def _check_filter_supported(self, search_request: SearchRequest[Any]) -> None: """Refuse a filter the service provider does not announce. - :rfc:`RFC7644 §3.4.2.2 <7644#section-3.4.2.2>`: "Providers MUST - decline to filter results if the specified filter operation is not - recognized and return an HTTP 400 error with a "scimType" error of - "invalidFilter" and an appropriate human-readable response as per - Section 3.12." + RFC7644 §3.4.2.2: "Providers MUST decline to filter results if the + specified filter operation is not recognized and return an HTTP 400 + error with a "scimType" error of "invalidFilter" and an appropriate + human-readable response as per Section 3.12." """ filter_config = self.get_service_provider_config().filter if search_request.filter and not (filter_config and filter_config.supported): @@ -584,15 +582,14 @@ def _check_filter_supported(self, search_request: SearchRequest[Any]) -> None: def _patch_supported(self) -> bool: """Tell whether the service provider announces PATCH. - :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "501 (Not - Implemented)": "Service provider does not support the request - operation, e.g., PATCH." + RFC7644 §3.12, Table 8, "501 (Not Implemented)": "Service provider + does not support the request operation, e.g., PATCH." """ patch_config = self.get_service_provider_config().patch return bool(patch_config and patch_config.supported) def _check_if_match(self, resource: Resource[Any]) -> None: - """:rfc:`RFC7644 §3.14 <7644#section-3.14>`. + """RFC7644 §3.14. "If the service provider supports versioning of resources, the client MAY supply an If-Match header (Section 3.1 of [RFC7232]) for @@ -611,7 +608,7 @@ def _check_if_match(self, resource: Resource[Any]) -> None: # -- Bulk operations --------------------------------------------- def _resolve_bulk_target(self, path: str) -> tuple[ResourceType | None, str | None]: - """Resolve a bulk operation's ``path`` to the resource type (and id, if any) it targets.""" + """Resolve a bulk operation's `path` to the resource type (and id, if any) it targets.""" resource_type = self._resource_type_at(path) if resource_type is not None: return resource_type, None @@ -622,9 +619,9 @@ def _run_bulk_operation(self, operation: BulkOperation[Any]) -> None: """Apply one bulk operation, and turn it into the description of its outcome. The target is resolved before the operation is applied, so a - failure still knows its location. :rfc:`RFC7644 §3.7 - <7644#section-3.7>`: "location The resource endpoint URL. REQUIRED - in a response, except in the event of a POST failure." + failure still knows its location. RFC7644 §3.7: "location The + resource endpoint URL. REQUIRED in a response, except in the event of + a POST failure." """ expected_version = operation.version operation.version = None @@ -721,10 +718,7 @@ def _run_bulk_operation(self, operation: BulkOperation[Any]) -> None: # -- Responses --------------------------------------------------- def _with_meta(self, resource_type: ResourceType, resource: Resource[Any]) -> Meta: - """Check the ``meta`` of a resource from the storage, and complete it. - - :return: The ``meta`` of the resource, once completed. - """ + """Check the `meta` of a resource from the storage, complete it, and return it.""" if resource.meta is None or resource.meta.resource_type != resource_type.name: name = resource.meta.resource_type if resource.meta else None raise ValueError( @@ -744,20 +738,17 @@ def _resource_response( ) -> Response: """Build a single-resource response. - :rfc:`RFC7643 §3.1 <7643#section-3.1>`: "location The URI of the - resource being returned. This value MUST be the same as the - "Content-Location" HTTP response header (see Section 3.1.4.2 of - [RFC7231])." + RFC7643 §3.1: "location The URI of the resource being returned. This + value MUST be the same as the "Content-Location" HTTP response header + (see Section 3.1.4.2 of [RFC7231])." - :rfc:`RFC7644 §3.3 <7644#section-3.3>`: "The URI of the created - resource SHALL include, in the HTTP "Location" header and the HTTP - body, a JSON representation [RFC7159] with the attribute - "meta.location"." + RFC7644 §3.3: "The URI of the created resource SHALL include, in the + HTTP "Location" header and the HTTP body, a JSON representation + [RFC7159] with the attribute "meta.location"." - :rfc:`RFC7644 §3.14 <7644#section-3.14>`: "When supported, SCIM ETags - MUST be specified as an HTTP header and SHOULD be specified within - the 'version' attribute contained in the resource's 'meta' - attribute." + RFC7644 §3.14: "When supported, SCIM ETags MUST be specified as an + HTTP header and SHOULD be specified within the 'version' attribute + contained in the resource's 'meta' attribute." """ meta = self._with_meta(resource_type, resource) response = jsonify(resource.model_dump(**dump_kwargs)) From deea9201deade4297bdb0a393e7acee2b6b63be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Rohrlich?= Date: Fri, 2 Oct 2026 16:15:44 +0200 Subject: [PATCH 7/7] refactor: improve _run_bulk_operation method and factorize methods between bulk and standard requests --- src/scim2_flask/extension.py | 260 ++++++++++++++++++++--------------- tests/test_bulk.py | 80 +++++++++++ 2 files changed, 227 insertions(+), 113 deletions(-) diff --git a/src/scim2_flask/extension.py b/src/scim2_flask/extension.py index c48f6b1..e1e1749 100644 --- a/src/scim2_flask/extension.py +++ b/src/scim2_flask/extension.py @@ -37,11 +37,12 @@ from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig from scim2_models import Sort +from werkzeug.datastructures import ETags from werkzeug.exceptions import Forbidden from werkzeug.exceptions import HTTPException from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotImplemented as HTTPNotImplemented -from werkzeug.exceptions import PreconditionFailed +from werkzeug.http import parse_etags from .storage import ScimStorage @@ -60,6 +61,42 @@ class PayloadTooLargeException(SCIMException): status = HTTPStatus.REQUEST_ENTITY_TOO_LARGE +class PreconditionFailedException(SCIMException): + """An operation whose expected version is not the resource's current one. + + :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "412 (Precondition + Failed)": "Failed to update. Resource has changed on the server." No + scimType of Table 9 goes with that status, so the hierarchy scim2-models + exposes is extended with it. + """ + + status = HTTPStatus.PRECONDITION_FAILED + + +class NotFoundException(SCIMException): + """A bulk operation whose path designates no endpoint or resource. + + :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "404 (Not Found)": + "Specified resource (e.g., User) or endpoint does not exist." No + scimType of Table 9 goes with that status, so the hierarchy scim2-models + exposes is extended with it. + """ + + status = HTTPStatus.NOT_FOUND + + +class NotImplementedException(SCIMException): + """An operation the service provider does not announce. + + :rfc:`RFC7644 §3.12 <7644#section-3.12>`, Table 8, "501 (Not + Implemented)": "Service provider does not support the request + operation, e.g., PATCH." No scimType of Table 9 goes with that status, + so the hierarchy scim2-models exposes is extended with it. + """ + + status = HTTPStatus.NOT_IMPLEMENTED + + class SCIM2: """Flask extension exposing a SCIM 2.0 server backed by a :class:`ScimStorage`. @@ -354,13 +391,12 @@ def replace_view(resource_id: str) -> Any: response_parameters = ResponseParameters.model_validate( request.args.to_dict() ) - original = self.storage.query(resource_type, resource_id) - self._check_if_match(original) payload = model.model_validate_json( request.data, scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST ) - payload.replace(original) - updated = self.storage.update(resource_type, payload) + updated = self._replace( + resource_type, resource_id, payload, request.if_match + ) return self._resource_response( resource_type, updated, @@ -374,18 +410,15 @@ def patch_view(resource_id: str) -> Any: response_parameters = ResponseParameters.model_validate( request.args.to_dict() ) - if not self._patch_supported(): - raise HTTPNotImplemented("PATCH is not supported") - resource = self.storage.query(resource_type, resource_id) - self._check_if_match(resource) patch_op = PatchOp[model].model_validate_json( request.data, scim_ctx=Context.RESOURCE_PATCH_REQUEST ) - if patch_op.patch(resource): - resource = self.storage.update(resource_type, resource) + patched = self._patch( + resource_type, resource_id, patch_op, request.if_match + ) return self._resource_response( resource_type, - resource, + patched, { "scim_ctx": Context.RESOURCE_PATCH_RESPONSE, "response_parameters": response_parameters, @@ -393,9 +426,7 @@ def patch_view(resource_id: str) -> Any: ) def delete_view(resource_id: str) -> Any: - if request.if_match: - self._check_if_match(self.storage.query(resource_type, resource_id)) - self.storage.delete(resource_type, resource_id) + self._delete(resource_type, resource_id, request.if_match) return "", HTTPStatus.NO_CONTENT blueprint.add_url_rule( @@ -579,141 +610,144 @@ def _check_filter_supported(self, search_request: SearchRequest[Any]) -> None: detail="Filtering is not supported by this service provider." ) - def _patch_supported(self) -> bool: - """Tell whether the service provider announces PATCH. + def _check_patch_supported(self) -> None: + """Refuse PATCH if the service provider does not announce it. RFC7644 §3.12, Table 8, "501 (Not Implemented)": "Service provider does not support the request operation, e.g., PATCH." """ patch_config = self.get_service_provider_config().patch - return bool(patch_config and patch_config.supported) + if not (patch_config and patch_config.supported): + raise NotImplementedException(detail="PATCH is not supported") + + def _check_version(self, resource: Resource[Any], if_match: ETags) -> None: + """Refuse an operation if the client's ETags do not match the resource's version. - def _check_if_match(self, resource: Resource[Any]) -> None: - """RFC7644 §3.14. + RFC7644 §3.14: "If the service provider supports versioning of + resources, the client MAY supply an If-Match header (Section 3.1 of + [RFC7232]) for PUT and PATCH operations to ensure that the requested + operation succeeds only if the supplied ETag matches the latest + service provider resource [...]." - "If the service provider supports versioning of resources, the - client MAY supply an If-Match header (Section 3.1 of [RFC7232]) for - PUT and PATCH operations to ensure that the requested operation - succeeds only if the supplied ETag matches the latest service - provider resource [...]." + RFC7644 §3.7: "Version MAY be used if the service provider supports + entity-tags (ETags) (Section 2.3 of [RFC7232]) and "method" is "PUT", + "PATCH", or "DELETE"." """ - if not request.if_match: + if not if_match: return version = resource.meta.version if resource.meta else None if version is None: return - if not request.if_match.contains_raw(version): - raise PreconditionFailed("ETag mismatch") - - # -- Bulk operations --------------------------------------------- + if not if_match.contains_raw(version): + raise PreconditionFailedException(detail="ETag mismatch") - def _resolve_bulk_target(self, path: str) -> tuple[ResourceType | None, str | None]: - """Resolve a bulk operation's `path` to the resource type (and id, if any) it targets.""" - resource_type = self._resource_type_at(path) - if resource_type is not None: - return resource_type, None - endpoint, _, resource_id = path.rpartition("/") - return self._resource_type_at(endpoint), resource_id + # -- Operations -------------------------------------------------- - def _run_bulk_operation(self, operation: BulkOperation[Any]) -> None: - """Apply one bulk operation, and turn it into the description of its outcome. + def _replace( + self, + resource_type: ResourceType, + resource_id: str, + replacement: Resource[Any], + if_match: ETags, + ) -> Resource[Any]: + """Replace a resource, and return the stored representation.""" + original = self.storage.query(resource_type, resource_id) + self._check_version(original, if_match) + replacement.replace(original) + return self.storage.update(resource_type, replacement) + + def _patch( + self, + resource_type: ResourceType, + resource_id: str, + patch_op: PatchOp[Any], + if_match: ETags, + ) -> Resource[Any]: + """Patch a resource, and return the stored representation.""" + self._check_patch_supported() + resource = self.storage.query(resource_type, resource_id) + self._check_version(resource, if_match) + if patch_op.patch(resource): + resource = self.storage.update(resource_type, resource) + return resource + + def _delete( + self, resource_type: ResourceType, resource_id: str, if_match: ETags + ) -> None: + """Delete a resource. - The target is resolved before the operation is applied, so a - failure still knows its location. RFC7644 §3.7: "location The - resource endpoint URL. REQUIRED in a response, except in the event of - a POST failure." + The resource is only read to check its version: the storage reports + a missing one on its own. """ - expected_version = operation.version - operation.version = None - assert operation.path is not None + if if_match: + self._check_version( + self.storage.query(resource_type, resource_id), if_match + ) + self.storage.delete(resource_type, resource_id) + + # -- Bulk operations --------------------------------------------- - resource_type, resource_id = self._resolve_bulk_target(operation.path) + def _locate_bulk_resource( + self, operation: BulkOperation[Any] + ) -> tuple[ResourceType, str]: + """Return the resource type and id a bulk PUT, PATCH or DELETE targets. + + The location of the operation is set first, as it is due even if + the operation fails. RFC7644 §3.7.3: "A "location" attribute that + includes the resource's endpoint MUST be returned for all operations + except for failed POST operations (which have no location)." + """ + path = operation.path + endpoint, _, resource_id = path.rpartition("/") + resource_type = self._resource_type_at(endpoint) if resource_type is None: - if operation.method != BulkOperation.Method.post: - # RFC7644 §3.7.3: "A "location" attribute that includes - # the resource's endpoint MUST be returned for all operations - # except for failed POST operations (which have no - # location)." That holds even when no resource type answers - # the path. - operation.location = url_for( - "scim2.not_found", - _path=operation.path.lstrip("/"), - _external=True, - ) - operation.status = HTTPStatus.NOT_FOUND - operation.response = Error( - status=HTTPStatus.NOT_FOUND, - detail=f"{operation.path!r} does not designate a known resource type", + operation.location = url_for( + "scim2.not_found", _path=path.lstrip("/"), _external=True ) - return + raise NotFoundException(detail=f"No resource at {path!r}") + operation.location = self.resource_location(resource_type, resource_id) + return resource_type, resource_id - if operation.method != BulkOperation.Method.post: - # RFC7644 §3.7.3: "A "location" attribute that includes the - # resource's endpoint MUST be returned for all operations - # except for failed POST operations (which have no - # location)." So it is set once here, ahead of success or - # failure, rather than duplicated in every branch below. - assert resource_id is not None - operation.location = self.resource_location(resource_type, resource_id) - - if ( - operation.method == BulkOperation.Method.patch - and not self._patch_supported() - ): - operation.status = HTTPStatus.NOT_IMPLEMENTED - operation.response = Error( - status=HTTPStatus.NOT_IMPLEMENTED, detail="PATCH is not supported" - ) - return + def _run_bulk_operation(self, operation: BulkOperation[Any]) -> None: + """Apply one bulk operation, and turn it into the description of its outcome.""" + if_match = parse_etags(operation.version) + operation.version = None try: if operation.method == BulkOperation.Method.post: + resource_type = self._resource_type_at(operation.path) + if resource_type is None: + raise NotFoundException(detail=f"No endpoint at {operation.path!r}") created = self.storage.create(resource_type, operation.data) meta = self._with_meta(resource_type, created) operation.status = HTTPStatus.CREATED operation.location = meta.location operation.version = meta.version - return - assert resource_id is not None - original = self.storage.query(resource_type, resource_id) + elif operation.method == BulkOperation.Method.put: + resource_type, resource_id = self._locate_bulk_resource(operation) + replaced = self._replace( + resource_type, resource_id, operation.data, if_match + ) + operation.status = HTTPStatus.OK + operation.version = self._with_meta(resource_type, replaced).version - # RFC7644 §3.7: "Version MAY be used if the service provider - # supports entity-tags (ETags) (Section 2.3 of [RFC7232]) and - # "method" is "PUT", "PATCH", or "DELETE"." - current_version = original.meta.version if original.meta else None - if ( - expected_version is not None - and current_version is not None - and expected_version != current_version - ): - operation.status = HTTPStatus.PRECONDITION_FAILED - operation.response = Error( - status=HTTPStatus.PRECONDITION_FAILED, detail="ETag mismatch" + elif operation.method == BulkOperation.Method.patch: + resource_type, resource_id = self._locate_bulk_resource(operation) + patched = self._patch( + resource_type, resource_id, operation.data, if_match ) - return + operation.status = HTTPStatus.OK + operation.version = self._with_meta(resource_type, patched).version - if operation.method == BulkOperation.Method.delete: - self.storage.delete(resource_type, resource_id) + elif operation.method == BulkOperation.Method.delete: + resource_type, resource_id = self._locate_bulk_resource(operation) + self._delete(resource_type, resource_id, if_match) operation.status = HTTPStatus.NO_CONTENT - return - - if operation.method == BulkOperation.Method.patch: - if operation.data.patch(original): - original = self.storage.update(resource_type, original) - else: - operation.data.replace(original) - original = self.storage.update(resource_type, operation.data) - - meta = self._with_meta(resource_type, original) - operation.status = HTTPStatus.OK - operation.version = meta.version - return except SCIMException as exc: operation.status = exc.status operation.response = exc.to_error() - return # -- Responses --------------------------------------------------- diff --git a/tests/test_bulk.py b/tests/test_bulk.py index f2d3d42..7cfe291 100644 --- a/tests/test_bulk.py +++ b/tests/test_bulk.py @@ -164,6 +164,68 @@ def test_bulk_operation_errors_are_embedded_per_operation(scim_client): assert dupe.response.scim_type == "uniqueness" +@pytest.mark.parametrize( + ("method", "data"), + [ + ("PUT", User[EnterpriseUser](user_name="whoever")), + ( + "PATCH", + PatchOp[User[EnterpriseUser]]( + operations=[ + PatchOperation(op="replace", path="displayName", value="Nobody") + ] + ), + ), + ("DELETE", None), + ], + ids=["PUT", "PATCH", "DELETE"], +) +def test_bulk_operation_without_resource_id_is_not_found(scim_client, method, data): + # RFC7644 §3.12, Table 8, "404 (Not Found)": "Specified resource (e.g., + # User) or endpoint does not exist." A PUT, PATCH or DELETE on the + # endpoint itself designates no resource, as outside a bulk. + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method=method, bulk_id="no-id", path="/Users", data=data + ), + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="next", + path="/Users", + data=User[EnterpriseUser](user_name="still-created"), + ), + ] + ) + ) + no_id, created = response.operations + assert no_id.status == 404 + assert no_id.location == "http://localhost/scim/v2/Users" + assert created.status == 201 + + +def test_bulk_post_on_a_resource_is_not_found(scim_client): + # As outside a bulk, a POST creates a resource on an endpoint, not at a + # given resource location. + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="POST", + bulk_id="with-id", + path="/Users/chosen-id", + data=User[EnterpriseUser](user_name="not-created"), + ) + ] + ) + ) + assert response.operations[0].status == 404 + # RFC7644 §3.7.3: failed POST operations "have no location". + assert response.operations[0].location is None + assert scim_client.query(User[EnterpriseUser]).total_results == 0 + + @pytest.mark.parametrize("chunked", [False, True]) def test_bulk_rejects_job_exceeding_max_payload_size(client, chunked): # RFC7644 §3.7.4: "The service provider MUST define the maximum @@ -220,6 +282,24 @@ def test_bulk_stale_operation_version_returns_412(scim_client): assert reloaded.display_name is None +def test_bulk_delete_with_stale_version_returns_412(scim_client): + created = scim_client.create(User[EnterpriseUser](user_name="bulk-kept")) + + response = scim_client.bulk( + BulkRequest[User[EnterpriseUser]]( + operations=[ + BulkOperation[User[EnterpriseUser]]( + method="DELETE", + path=f"/Users/{created.id}", + version='W/"stale"', + ) + ] + ) + ) + assert response.operations[0].status == 412 + assert scim_client.query(User[EnterpriseUser], created.id).id == created.id + + def test_bulk_delete_result_carries_no_version(scim_client): created = scim_client.create(User[EnterpriseUser](user_name="bulk-deleted"))