From ebe89e2c0d44a486198bc907dbfa83a4e0449ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 17 Sep 2026 10:38:10 +0200 Subject: [PATCH] fix: migrate from httpx to httpx2 --- README.md | 10 +- doc/changelog.rst | 18 + doc/reference.rst | 2 +- doc/tutorial.rst | 27 +- pyproject.toml | 14 +- scim2_client/engines/httpx.py | 519 +---------------- scim2_client/engines/httpx2.py | 550 ++++++++++++++++++ scim2_client/errors.py | 4 +- tests/conftest.py | 4 +- .../engines/{test_httpx.py => test_httpx2.py} | 8 +- tests/engines/test_httpx_compatibility.py | 113 ++++ tests/test_create.py | 2 +- tests/test_delete.py | 2 +- tests/test_discovery.py | 4 +- tests/test_modify.py | 2 +- tests/test_query.py | 4 +- tests/test_replace.py | 2 +- tests/test_search.py | 6 +- tests/test_utils.py | 2 +- uv.lock | 192 +++--- 20 files changed, 871 insertions(+), 614 deletions(-) create mode 100644 scim2_client/engines/httpx2.py rename tests/engines/{test_httpx.py => test_httpx2.py} (96%) create mode 100644 tests/engines/test_httpx_compatibility.py diff --git a/README.md b/README.md index 4431c6f..7e930c0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) , that pythonically build requests and parse responses, following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications. -You can use whatever request engine you prefer to perform network requests, but scim2-client comes with [httpx](https://github.com/encode/httpx) support. +You can use whatever request engine you prefer to perform network requests, but scim2-client comes with [httpx2](https://github.com/pydantic/httpx2) support. It aims to be used in SCIM client applications, or in unit tests for SCIM server applications. @@ -21,14 +21,14 @@ It allows users and groups creations, modifications and deletions to be synchron - **Server Discovery**: Automatic retrieval of `ServiceProviderConfig`, `ResourceTypes` and `Schemas` - **Search & Filtering**: Support for SCIM filters, sorting, pagination and attribute selection - **Sync & Async**: Both synchronous and asynchronous clients available -- **Multiple HTTP Engines**: Built-in support for [httpx](https://github.com/encode/httpx) (sync/async) and [werkzeug](https://werkzeug.palletsprojects.com/) (testing). Adaptable to any network engine. +- **Multiple HTTP Engines**: Built-in support for [httpx2](https://github.com/pydantic/httpx2) (sync/async) and [werkzeug](https://werkzeug.palletsprojects.com/) (testing). Adaptable to any network engine. - **Request & Response Validation**: Automatic payload validation against SCIM schemas - **Error Handling**: Structured exceptions for network, request and response errors ## Installation ```shell -pip install scim2-client[httpx] +pip install scim2-client[httpx2] ``` ## Usage @@ -40,9 +40,9 @@ Here is an example of usage: ```python import datetime -from httpx import Client +from httpx2 import Client from scim2_client import SCIMResponseErrorObject -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import SyncSCIMClient client = Client(base_url="https://auth.example/scim/v2", headers={"Authorization": "Bearer foobar"}) scim = SyncSCIMClient(client) diff --git a/doc/changelog.rst b/doc/changelog.rst index dc18d1c..6b55346 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,24 @@ Changelog ========= +[Unreleased] +------------ + +Added +^^^^^ +- The network request engines are built upon `httpx2 `_, + which is maintained, and live in ``scim2_client.engines.httpx2``. + They are shipped in the ``httpx2`` packaging extra. + `httpx `_ is still used when httpx2 is not installed. + +Deprecated +^^^^^^^^^^ +- The ``httpx`` packaging extra, in favor of the ``httpx2`` extra. Will be removed in 0.9. +- The ``scim2_client.engines.httpx`` module, in favor of ``scim2_client.engines.httpx2``. + Will be removed in 0.9. +- Passing a :code:`httpx.Client` or a :code:`httpx.AsyncClient` to the request engines, + in favor of their httpx2 counterparts. Will be removed in 0.9. + [0.7.5] - 2026-04-02 -------------------- diff --git a/doc/reference.rst b/doc/reference.rst index aa065db..dfd8434 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -1,7 +1,7 @@ Reference ========= -.. automodule:: scim2_client.engines.httpx +.. automodule:: scim2_client.engines.httpx2 :members: :member-order: bysource diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 0e85cf7..1746312 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -4,11 +4,18 @@ Tutorial Initialization ============== -scim2-client depends on request engines such as `httpx `_ to perform network requests. -This tutorial demonstrate how to use scim2-client with httpx, and suppose you have installed the `httpx` extra for example with ``pip install scim2-client[httpx]``. +scim2-client depends on request engines such as `httpx2 `_ to perform network requests. +This tutorial demonstrate how to use scim2-client with httpx2, and suppose you have installed the `httpx2` extra for example with ``pip install scim2-client[httpx2]``. -As a start you will need to instantiate a httpx :code:`Client` (or :code:`AsyncClient`) object that you can parameter as your will, and then pass it to a :class:`~scim2_client.SCIMClient` object. -In addition to your SCIM server root endpoint, you will probably want to provide some authorization headers through the httpx :code:`Client` :code:`headers` parameter: +.. note:: + + The engines still work with `httpx `_, shipped in the deprecated `httpx` extra. + It is used when httpx2 is not installed, and both its extra and its support will be removed in 0.9. + Applications that cannot migrate all their dependencies at once can call :code:`httpx2.alias_httpx()` + at the very top of their entrypoint, so that :code:`import httpx` resolves to httpx2 process-wide. + +As a start you will need to instantiate a httpx2 :code:`Client` (or :code:`AsyncClient`) object that you can parameter as your will, and then pass it to a :class:`~scim2_client.SCIMClient` object. +In addition to your SCIM server root endpoint, you will probably want to provide some authorization headers through the httpx2 :code:`Client` :code:`headers` parameter: .. tab-set:: :class: outline @@ -18,8 +25,8 @@ In addition to your SCIM server root endpoint, you will probably want to provide .. code-block:: python - from httpx import Client - from scim2_client.engines.httpx import SyncSCIMClient + from httpx2 import Client + from scim2_client.engines.httpx2 import SyncSCIMClient client = Client( base_url="https://auth.example/scim/v2", @@ -32,8 +39,8 @@ In addition to your SCIM server root endpoint, you will probably want to provide .. code-block:: python - from httpx import AsyncClient - from scim2_client.engines.httpx import AsyncSCIMClient + from httpx2 import AsyncClient + from scim2_client.engines.httpx2 import AsyncSCIMClient client = AsyncClient( base_url="https://auth.example/scim/v2", @@ -392,8 +399,8 @@ Engines scim2-client comes with a light abstraction layers that allows for different requests engines. Currently those engines are shipped: -- :class:`~scim2_client.engines.httpx.SyncSCIMClient`: A synchronous engine using `httpx `_ to perform the HTTP requests. -- :class:`~scim2_client.engines.httpx.AsyncSCIMClient`: An asynchronous engine using `httpx `_ to perform the HTTP requests. It has the very same API than its synchronous version, except it is asynchronous. +- :class:`~scim2_client.engines.httpx2.SyncSCIMClient`: A synchronous engine using `httpx2 `_ to perform the HTTP requests. +- :class:`~scim2_client.engines.httpx2.AsyncSCIMClient`: An asynchronous engine using `httpx2 `_ to perform the HTTP requests. It has the very same API than its synchronous version, except it is asynchronous. - :class:`~scim2_client.engines.werkzeug.TestSCIMClient`: A test engine for development purposes. It takes a WSGI app and directly execute the server code instead of performing real HTTP requests. This is faster in unit test suites, and helpful to catch the server exceptions. diff --git a/pyproject.toml b/pyproject.toml index cc28c19..1cfdf04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Pythonically build SCIM requests and parse SCIM responses" authors = [{name="Yaal Coop", email="contact@yaal.coop"}] license = {file = "LICENSE.md"} readme = "README.md" -keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644", "httpx", "api"] +keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644", "httpx2", "api"] classifiers = [ "Intended Audience :: Developers", "Development Status :: 3 - Alpha", @@ -31,6 +31,11 @@ dependencies = [ ] [project.optional-dependencies] +httpx2 = [ + "httpx2>=2.12", +] + +# Deprecated, will be removed in 0.9. httpx = [ "httpx>=0.28.0", ] @@ -50,6 +55,7 @@ module-root = "" [dependency-groups] dev = [ + "httpx>=0.28.0", "mypy>=1.13.0", "portpicker>=1.6.0", "prek>=0.1.0", @@ -139,13 +145,14 @@ env_list = [ "py312", "py313", "py314", + "legacy", "minversions", "doc", "coverage", ] [tool.tox.env_run_base] -extras = ["httpx", "werkzeug"] +extras = ["httpx2", "werkzeug"] runner = "uv-venv-lock-runner" dependency_groups = ["dev"] commands = [ @@ -158,6 +165,9 @@ commands = [ ["prek", "run", "--all-files", "--show-diff-on-failure"], ] +[tool.tox.env.legacy] +extras = ["httpx", "werkzeug"] + [tool.tox.env.minversions] runner = "uv-venv-runner" uv_resolution = "lowest-direct" diff --git a/scim2_client/engines/httpx.py b/scim2_client/engines/httpx.py index 874c428..0745090 100644 --- a/scim2_client/engines/httpx.py +++ b/scim2_client/engines/httpx.py @@ -1,511 +1,16 @@ -import json -import sys -from contextlib import contextmanager -from typing import TypeVar +"""Deprecated alias for :mod:`scim2_client.engines.httpx2`.""" -from httpx import AsyncClient -from httpx import Client -from httpx import RequestError -from httpx import Response -from scim2_models import AnyResource -from scim2_models import Context -from scim2_models import Error -from scim2_models import ListResponse -from scim2_models import PatchOp -from scim2_models import Resource -from scim2_models import ResponseParameters -from scim2_models import SearchRequest +import warnings -from scim2_client.client import BaseAsyncSCIMClient -from scim2_client.client import BaseSyncSCIMClient -from scim2_client.errors import RequestNetworkError -from scim2_client.errors import SCIMClientError -from scim2_client.errors import UnexpectedContentFormat +from scim2_client.engines.httpx2 import AsyncSCIMClient +from scim2_client.engines.httpx2 import SyncSCIMClient -ResourceT = TypeVar("ResourceT", bound=Resource) +warnings.warn( + "'scim2_client.engines.httpx' is deprecated, " + "use 'scim2_client.engines.httpx2' instead. " + "Will be removed in 0.9.", + DeprecationWarning, + stacklevel=2, +) - -@contextmanager -def handle_request_error(payload=None): - try: - yield - - except RequestError as exc: - scim_network_exc = RequestNetworkError(source=payload) - if sys.version_info >= (3, 11): # pragma: no cover - scim_network_exc.add_note(str(exc)) - raise scim_network_exc from exc - - -@contextmanager -def handle_response_error(response: Response): - try: - yield - - except json.decoder.JSONDecodeError as exc: - raise UnexpectedContentFormat(source=response) from exc - - except SCIMClientError as exc: - exc.source = response - raise exc - - -class SyncSCIMClient(BaseSyncSCIMClient): - """Perform SCIM requests over the network and validate responses. - - :param client: A :class:`httpx.Client` instance that will be used to send requests. - :param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client. - If a request payload describe a resource that is not in this list, an exception will be raised. - :param check_request_payload: If :data:`False`, - :code:`resource` is expected to be a dict that will be passed as-is in the request. - This value can be overwritten in methods. - :param check_response_payload: Whether to validate that the response payloads are valid. - If set, the raw payload will be returned. This value can be overwritten in methods. - :param raise_scim_errors: If :data:`True` and the server returned an - :class:`~scim2_models.Error` object during a request, a :class:`~scim2_client.SCIMResponseErrorObject` - exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. - """ - - def __init__(self, client: Client, *args, **kwargs): - super().__init__(*args, **kwargs) - self.client = client - - def create( - self, - resource: AnyResource | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.CREATION_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> AnyResource | Error | dict: - req = self._prepare_create_request( - resource=resource, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = self.client.post(req.url, json=req.payload, **req.request_kwargs) - - with handle_response_error(req.payload): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_CREATION_RESPONSE, - ) - - def query( - self, - resource_model: type[Resource] | None = None, - id: str | None = None, - query_parameters: ResponseParameters | dict | None = None, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.QUERY_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - search_request: ResponseParameters | dict | None = None, - **kwargs, - ) -> Resource | ListResponse[Resource] | Error | dict: - query_parameters = self._resolve_query_parameters( - query_parameters, search_request - ) - req = self._prepare_query_request( - resource_model=resource_model, - id=id, - query_parameters=query_parameters, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = self.client.get( - req.url, params=req.payload, **req.request_kwargs - ) - - with handle_response_error(req.payload): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) - - def search( - self, - search_request: SearchRequest | None = None, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.SEARCH_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> Resource | ListResponse[Resource] | Error | dict: - req = self._prepare_search_request( - search_request=search_request, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = self.client.post(req.url, json=req.payload, **req.request_kwargs) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) - - def delete( - self, - resource_model: type[Resource], - id: str, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> Error | dict | None: - req = self._prepare_delete_request( - resource_model=resource_model, - id=id, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(): - response = self.client.delete(req.url, **req.request_kwargs) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=expected_status_codes, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - ) - - def replace( - self, - resource: AnyResource | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> AnyResource | Error | dict: - req = self._prepare_replace_request( - resource=resource, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = self.client.put(req.url, json=req.payload, **req.request_kwargs) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, - ) - - def modify( - self, - resource_model: type[ResourceT], - id: str, - patch_op: PatchOp[ResourceT] | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> ResourceT | Error | dict | None: - req = self._prepare_patch_request( - resource_model=resource_model, - id=id, - patch_op=patch_op, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = self.client.patch( - req.url, json=req.payload, **req.request_kwargs - ) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_PATCH_RESPONSE, - ) - - -class AsyncSCIMClient(BaseAsyncSCIMClient): - """Perform SCIM requests over the network and validate responses. - - :param client: A :class:`httpx.AsyncClient` instance that will be used to send requests. - :param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client. - If a request payload describe a resource that is not in this list, an exception will be raised. - :param check_request_payload: If :data:`False`, - :code:`resource` is expected to be a dict that will be passed as-is in the request. - This value can be overwritten in methods. - :param check_response_payload: Whether to validate that the response payloads are valid. - If set, the raw payload will be returned. This value can be overwritten in methods. - :param raise_scim_errors: If :data:`True` and the server returned an - :class:`~scim2_models.Error` object during a request, a :class:`~scim2_client.SCIMResponseErrorObject` - exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. - - """ - - def __init__(self, client: AsyncClient, *args, **kwargs): - super().__init__(*args, **kwargs) - self.client = client - - async def create( - self, - resource: AnyResource | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.CREATION_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> AnyResource | Error | dict: - req = self._prepare_create_request( - resource=resource, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = await self.client.post( - req.url, json=req.payload, **req.request_kwargs - ) - - with handle_response_error(req.payload): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_CREATION_RESPONSE, - ) - - async def query( - self, - resource_model: type[Resource] | None = None, - id: str | None = None, - query_parameters: ResponseParameters | dict | None = None, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.QUERY_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - search_request: ResponseParameters | dict | None = None, - **kwargs, - ) -> Resource | ListResponse[Resource] | Error | dict: - query_parameters = self._resolve_query_parameters( - query_parameters, search_request - ) - req = self._prepare_query_request( - resource_model=resource_model, - id=id, - query_parameters=query_parameters, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = await self.client.get( - req.url, params=req.payload, **req.request_kwargs - ) - - with handle_response_error(req.payload): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) - - async def search( - self, - search_request: SearchRequest | None = None, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.SEARCH_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> Resource | ListResponse[Resource] | Error | dict: - req = self._prepare_search_request( - search_request=search_request, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = await self.client.post( - req.url, json=req.payload, **req.request_kwargs - ) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ) - - async def delete( - self, - resource_model: type[Resource], - id: str, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> Error | dict | None: - req = self._prepare_delete_request( - resource_model=resource_model, - id=id, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(): - response = await self.client.delete(req.url, **req.request_kwargs) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=expected_status_codes, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - ) - - async def replace( - self, - resource: AnyResource | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> AnyResource | Error | dict: - req = self._prepare_replace_request( - resource=resource, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = await self.client.put( - req.url, json=req.payload, **req.request_kwargs - ) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, - ) - - async def modify( - self, - resource_model: type[ResourceT], - id: str, - patch_op: PatchOp[ResourceT] | dict, - check_request_payload: bool | None = None, - check_response_payload: bool | None = None, - expected_status_codes: list[int] - | None = BaseAsyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, - raise_scim_errors: bool | None = None, - **kwargs, - ) -> ResourceT | Error | dict | None: - req = self._prepare_patch_request( - resource_model=resource_model, - id=id, - patch_op=patch_op, - check_request_payload=check_request_payload, - expected_status_codes=expected_status_codes, - **kwargs, - ) - - with handle_request_error(req.payload): - response = await self.client.patch( - req.url, json=req.payload, **req.request_kwargs - ) - - with handle_response_error(response): - return self.check_response( - payload=response.json() if response.text else None, - status_code=response.status_code, - headers=response.headers, - expected_status_codes=req.expected_status_codes, - expected_types=req.expected_types, - check_response_payload=check_response_payload, - raise_scim_errors=raise_scim_errors, - scim_ctx=Context.RESOURCE_PATCH_RESPONSE, - ) +__all__ = ["AsyncSCIMClient", "SyncSCIMClient"] diff --git a/scim2_client/engines/httpx2.py b/scim2_client/engines/httpx2.py new file mode 100644 index 0000000..0493a97 --- /dev/null +++ b/scim2_client/engines/httpx2.py @@ -0,0 +1,550 @@ +import json +import sys +import warnings +from contextlib import contextmanager +from typing import Any +from typing import TypeVar + +try: + from httpx2 import AsyncClient + from httpx2 import Client + from httpx2 import Response +except ImportError: + warnings.warn( + "httpx2 is not installed, falling back on httpx. " + "The httpx support is deprecated, install 'scim2-client[httpx2]' instead. " + "Will be removed in 0.9.", + DeprecationWarning, + stacklevel=2, + ) + from httpx import AsyncClient # type: ignore[assignment] + from httpx import Client # type: ignore[assignment] + from httpx import Response # type: ignore[assignment] + +from scim2_models import AnyResource +from scim2_models import Context +from scim2_models import Error +from scim2_models import ListResponse +from scim2_models import PatchOp +from scim2_models import Resource +from scim2_models import ResponseParameters +from scim2_models import SearchRequest + +from scim2_client.client import BaseAsyncSCIMClient +from scim2_client.client import BaseSyncSCIMClient +from scim2_client.errors import RequestNetworkError +from scim2_client.errors import SCIMClientError +from scim2_client.errors import UnexpectedContentFormat + +ResourceT = TypeVar("ResourceT", bound=Resource) + + +def _request_error_classes() -> tuple[type[BaseException], ...]: + """Return the ``RequestError`` classes of the httpx flavors that are in use.""" + modules: tuple[Any, ...] = (sys.modules.get("httpx2"), sys.modules.get("httpx")) + return tuple(module.RequestError for module in modules if module is not None) + + +def _warn_legacy_client(client: Any) -> None: + """Warn when a httpx client is used while httpx2 is the flavor in use.""" + httpx: Any = sys.modules.get("httpx") + if httpx is None or Client is httpx.Client: + return + + if not isinstance(client, (httpx.Client, httpx.AsyncClient)): + return + + warnings.warn( + "Passing a httpx client is deprecated, pass a httpx2 client instead. " + "Will be removed in 0.9.", + DeprecationWarning, + stacklevel=3, + ) + + +@contextmanager +def handle_request_error(payload=None): + try: + yield + + except _request_error_classes() as exc: + scim_network_exc = RequestNetworkError(source=payload) + if sys.version_info >= (3, 11): # pragma: no cover + scim_network_exc.add_note(str(exc)) + raise scim_network_exc from exc + + +@contextmanager +def handle_response_error(response: Response): + try: + yield + + except json.decoder.JSONDecodeError as exc: + raise UnexpectedContentFormat(source=response) from exc + + except SCIMClientError as exc: + exc.source = response + raise exc + + +class SyncSCIMClient(BaseSyncSCIMClient): + """Perform SCIM requests over the network and validate responses. + + :param client: A :class:`httpx2.Client` instance that will be used to send requests. + :param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client. + If a request payload describe a resource that is not in this list, an exception will be raised. + :param check_request_payload: If :data:`False`, + :code:`resource` is expected to be a dict that will be passed as-is in the request. + This value can be overwritten in methods. + :param check_response_payload: Whether to validate that the response payloads are valid. + If set, the raw payload will be returned. This value can be overwritten in methods. + :param raise_scim_errors: If :data:`True` and the server returned an + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_client.SCIMResponseErrorObject` + exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. + """ + + def __init__(self, client: Client, *args, **kwargs): + super().__init__(*args, **kwargs) + _warn_legacy_client(client) + self.client = client + + def create( + self, + resource: AnyResource | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.CREATION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> AnyResource | Error | dict: + req = self._prepare_create_request( + resource=resource, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.post(req.url, json=req.payload, **req.request_kwargs) + + with handle_response_error(req.payload): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + ) + + def query( + self, + resource_model: type[Resource] | None = None, + id: str | None = None, + query_parameters: ResponseParameters | dict | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.QUERY_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + search_request: ResponseParameters | dict | None = None, + **kwargs, + ) -> Resource | ListResponse[Resource] | Error | dict: + query_parameters = self._resolve_query_parameters( + query_parameters, search_request + ) + req = self._prepare_query_request( + resource_model=resource_model, + id=id, + query_parameters=query_parameters, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.get( + req.url, params=req.payload, **req.request_kwargs + ) + + with handle_response_error(req.payload): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + def search( + self, + search_request: SearchRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.SEARCH_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> Resource | ListResponse[Resource] | Error | dict: + req = self._prepare_search_request( + search_request=search_request, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.post(req.url, json=req.payload, **req.request_kwargs) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + def delete( + self, + resource_model: type[Resource], + id: str, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> Error | dict | None: + req = self._prepare_delete_request( + resource_model=resource_model, + id=id, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(): + response = self.client.delete(req.url, **req.request_kwargs) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=expected_status_codes, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + ) + + def replace( + self, + resource: AnyResource | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> AnyResource | Error | dict: + req = self._prepare_replace_request( + resource=resource, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.put(req.url, json=req.payload, **req.request_kwargs) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + ) + + def modify( + self, + resource_model: type[ResourceT], + id: str, + patch_op: PatchOp[ResourceT] | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseSyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> ResourceT | Error | dict | None: + req = self._prepare_patch_request( + resource_model=resource_model, + id=id, + patch_op=patch_op, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = self.client.patch( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_PATCH_RESPONSE, + ) + + +class AsyncSCIMClient(BaseAsyncSCIMClient): + """Perform SCIM requests over the network and validate responses. + + :param client: A :class:`httpx2.AsyncClient` instance that will be used to send requests. + :param resource_models: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIM client. + If a request payload describe a resource that is not in this list, an exception will be raised. + :param check_request_payload: If :data:`False`, + :code:`resource` is expected to be a dict that will be passed as-is in the request. + This value can be overwritten in methods. + :param check_response_payload: Whether to validate that the response payloads are valid. + If set, the raw payload will be returned. This value can be overwritten in methods. + :param raise_scim_errors: If :data:`True` and the server returned an + :class:`~scim2_models.Error` object during a request, a :class:`~scim2_client.SCIMResponseErrorObject` + exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods. + + """ + + def __init__(self, client: AsyncClient, *args, **kwargs): + super().__init__(*args, **kwargs) + _warn_legacy_client(client) + self.client = client + + async def create( + self, + resource: AnyResource | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.CREATION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> AnyResource | Error | dict: + req = self._prepare_create_request( + resource=resource, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.post( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(req.payload): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + ) + + async def query( + self, + resource_model: type[Resource] | None = None, + id: str | None = None, + query_parameters: ResponseParameters | dict | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.QUERY_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + search_request: ResponseParameters | dict | None = None, + **kwargs, + ) -> Resource | ListResponse[Resource] | Error | dict: + query_parameters = self._resolve_query_parameters( + query_parameters, search_request + ) + req = self._prepare_query_request( + resource_model=resource_model, + id=id, + query_parameters=query_parameters, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.get( + req.url, params=req.payload, **req.request_kwargs + ) + + with handle_response_error(req.payload): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + async def search( + self, + search_request: SearchRequest | None = None, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.SEARCH_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> Resource | ListResponse[Resource] | Error | dict: + req = self._prepare_search_request( + search_request=search_request, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.post( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + async def delete( + self, + resource_model: type[Resource], + id: str, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.DELETION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> Error | dict | None: + req = self._prepare_delete_request( + resource_model=resource_model, + id=id, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(): + response = await self.client.delete(req.url, **req.request_kwargs) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=expected_status_codes, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + ) + + async def replace( + self, + resource: AnyResource | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> AnyResource | Error | dict: + req = self._prepare_replace_request( + resource=resource, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.put( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + ) + + async def modify( + self, + resource_model: type[ResourceT], + id: str, + patch_op: PatchOp[ResourceT] | dict, + check_request_payload: bool | None = None, + check_response_payload: bool | None = None, + expected_status_codes: list[int] + | None = BaseAsyncSCIMClient.PATCH_RESPONSE_STATUS_CODES, + raise_scim_errors: bool | None = None, + **kwargs, + ) -> ResourceT | Error | dict | None: + req = self._prepare_patch_request( + resource_model=resource_model, + id=id, + patch_op=patch_op, + check_request_payload=check_request_payload, + expected_status_codes=expected_status_codes, + **kwargs, + ) + + with handle_request_error(req.payload): + response = await self.client.patch( + req.url, json=req.payload, **req.request_kwargs + ) + + with handle_response_error(response): + return self.check_response( + payload=response.json() if response.text else None, + status_code=response.status_code, + headers=response.headers, + expected_status_codes=req.expected_status_codes, + expected_types=req.expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_PATCH_RESPONSE, + ) diff --git a/scim2_client/errors.py b/scim2_client/errors.py index deb02a5..a779d8a 100644 --- a/scim2_client/errors.py +++ b/scim2_client/errors.py @@ -31,8 +31,8 @@ class SCIMRequestError(SCIMClientError): class RequestNetworkError(SCIMRequestError): """Error raised when a network error happened during request. - This error is raised when a :class:`httpx.RequestError` has been caught while performing a request. - The original :class:`~httpx.RequestError` is available with :attr:`~BaseException.__cause__`. + This error is raised when a :class:`httpx2.RequestError` has been caught while performing a request. + The original :class:`~httpx2.RequestError` is available with :attr:`~BaseException.__cause__`. """ def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index ebcbab7..824ddb9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,9 @@ import pytest -from httpx import Client from scim2_models import Group from scim2_models import User -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient @pytest.fixture diff --git a/tests/engines/test_httpx.py b/tests/engines/test_httpx2.py similarity index 96% rename from tests/engines/test_httpx.py rename to tests/engines/test_httpx2.py index 8d001a7..11bbef8 100644 --- a/tests/engines/test_httpx.py +++ b/tests/engines/test_httpx2.py @@ -3,15 +3,15 @@ import portpicker import pytest -from httpx import AsyncClient -from httpx import Client from scim2_models import PatchOp from scim2_models import PatchOperation from scim2_models import SearchRequest from scim2_models import ServiceProviderConfig -from scim2_client.engines.httpx import AsyncSCIMClient -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import AsyncClient +from scim2_client.engines.httpx2 import AsyncSCIMClient +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient from scim2_client.errors import SCIMResponseErrorObject scim2_server = pytest.importorskip("scim2_server") diff --git a/tests/engines/test_httpx_compatibility.py b/tests/engines/test_httpx_compatibility.py new file mode 100644 index 0000000..93fd362 --- /dev/null +++ b/tests/engines/test_httpx_compatibility.py @@ -0,0 +1,113 @@ +import importlib +import sys +import warnings + +import httpx +import pytest +from scim2_models import User + +from scim2_client.engines import httpx2 as engine +from scim2_client.engines.httpx2 import AsyncSCIMClient +from scim2_client.engines.httpx2 import SyncSCIMClient +from scim2_client.engines.httpx2 import _request_error_classes +from scim2_client.errors import RequestNetworkError + +requires_httpx2 = pytest.mark.skipif( + engine.Client is httpx.Client, reason="httpx2 is not installed" +) + + +def test_legacy_engine_module_is_deprecated(): + """Importing scim2_client.engines.httpx emits a deprecation warning.""" + legacy = importlib.import_module("scim2_client.engines.httpx") + with pytest.warns(DeprecationWarning, match="scim2_client.engines.httpx"): + importlib.reload(legacy) + + assert legacy.SyncSCIMClient is engine.SyncSCIMClient + assert legacy.AsyncSCIMClient is engine.AsyncSCIMClient + + +@requires_httpx2 +def test_engine_falls_back_on_httpx_when_httpx2_is_missing(): + """The engine imports httpx and warns when httpx2 is unavailable.""" + httpx2 = sys.modules["httpx2"] + sys.modules["httpx2"] = None + try: + with pytest.warns(DeprecationWarning, match="httpx2 is not installed"): + importlib.reload(engine) + + assert engine.Client is httpx.Client + assert engine.AsyncClient is httpx.AsyncClient + assert engine.Response is httpx.Response + + finally: + sys.modules["httpx2"] = httpx2 + importlib.reload(engine) + + +@requires_httpx2 +def test_httpx_client_is_deprecated(httpserver): + """Passing a httpx client to the engine emits a deprecation warning.""" + with httpx.Client(base_url=f"http://localhost:{httpserver.port}") as client: + with pytest.warns(DeprecationWarning, match="httpx client is deprecated"): + SyncSCIMClient(client, resource_models=[User]) + + +@requires_httpx2 +async def test_httpx_async_client_is_deprecated(httpserver): + """Passing a httpx async client to the engine emits a deprecation warning.""" + async with httpx.AsyncClient( + base_url=f"http://localhost:{httpserver.port}" + ) as client: + with pytest.warns(DeprecationWarning, match="httpx client is deprecated"): + AsyncSCIMClient(client, resource_models=[User]) + + +@requires_httpx2 +def test_network_errors_of_httpx_clients_are_converted(httpserver): + """Network errors raised by a httpx client become RequestNetworkError.""" + with httpx.Client(base_url=f"http://localhost:{httpserver.port}") as client: + with pytest.warns(DeprecationWarning): + scim_client = SyncSCIMClient(client, resource_models=[User]) + + with pytest.raises( + RequestNetworkError, match="Network error happened during request" + ): + scim_client.query(url="http://invalid.test") + + +@requires_httpx2 +def test_httpx_clients_are_accepted_when_httpx_is_the_flavor_in_use( + monkeypatch, httpserver +): + """A httpx client raises no warning when the engine itself runs on httpx.""" + monkeypatch.setattr(engine, "Client", httpx.Client) + with httpx.Client(base_url=f"http://localhost:{httpserver.port}") as client: + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + SyncSCIMClient(client, resource_models=[User]) + + +def test_client_flavor_is_not_checked_when_httpx_is_not_imported( + monkeypatch, httpserver +): + """Clients are not inspected when httpx is absent from the interpreter.""" + monkeypatch.delitem(sys.modules, "httpx", raising=False) + with engine.Client(base_url=f"http://localhost:{httpserver.port}") as client: + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + SyncSCIMClient(client, resource_models=[User]) + + +@requires_httpx2 +def test_request_errors_of_every_imported_flavor_are_caught(): + """Every imported httpx flavor contributes its RequestError class.""" + classes = _request_error_classes() + assert httpx.RequestError in classes + assert sys.modules["httpx2"].RequestError in classes + + +def test_request_errors_of_absent_flavors_are_ignored(monkeypatch): + """A flavor that is not imported contributes no RequestError class.""" + monkeypatch.delitem(sys.modules, "httpx", raising=False) + assert httpx.RequestError not in _request_error_classes() diff --git a/tests/test_create.py b/tests/test_create.py index 9b0d24c..ba4caa2 100644 --- a/tests/test_create.py +++ b/tests/test_create.py @@ -278,7 +278,7 @@ def test_request_validation_error(sync_client): def test_request_network_error(sync_client): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" user_request = User(user_name="bjensen@example.com") with pytest.raises( RequestNetworkError, match="Network error happened during request" diff --git a/tests/test_delete.py b/tests/test_delete.py index 465e3d5..081b1d4 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -86,7 +86,7 @@ def test_dont_check_response_payload(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" with pytest.raises( RequestNetworkError, match="Network error happened during request" ): diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 3a0c46f..6656d42 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -3,7 +3,6 @@ import portpicker import pytest -from httpx import Client from scim2_models import EnterpriseUser from scim2_models import Extension from scim2_models import Group @@ -11,7 +10,8 @@ from scim2_models import ResourceType from scim2_models import User -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient scim2_server = pytest.importorskip("scim2_server") from scim2_server.backend import InMemoryBackend # noqa: E402 diff --git a/tests/test_modify.py b/tests/test_modify.py index cef4956..d82d825 100644 --- a/tests/test_modify.py +++ b/tests/test_modify.py @@ -348,7 +348,7 @@ def test_request_validation_error(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" operation = PatchOperation( op=PatchOperation.Op.replace_, path="displayName", value="Test" ) diff --git a/tests/test_query.py b/tests/test_query.py index dceb548..7538584 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -401,7 +401,7 @@ def test_all_users(sync_client): def test_custom_url(sync_client): - """Test that querying by passing the 'url' parameter directly to httpx is accepted.""" + """Test that querying by passing the 'url' parameter directly to httpx2 is accepted.""" response = sync_client.query(url="/Users/2819c223-7f76-453a-919d-413861904646") assert response == User( id="2819c223-7f76-453a-919d-413861904646", @@ -678,7 +678,7 @@ def test_service_provider_config_endpoint_with_an_id(sync_client): def test_request_network_error(sync_client): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" with pytest.raises( RequestNetworkError, match="Network error happened during request" ): diff --git a/tests/test_replace.py b/tests/test_replace.py index 0372dcc..51e2fe0 100644 --- a/tests/test_replace.py +++ b/tests/test_replace.py @@ -304,7 +304,7 @@ def test_request_validation_error(httpserver, sync_client): def test_request_network_error(httpserver, sync_client): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" user_request = User(user_name="bjensen@example.com", id="anything") with pytest.raises( RequestNetworkError, match="Network error happened during request" diff --git a/tests/test_search.py b/tests/test_search.py index e1540bc..d3645fa 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,7 +1,6 @@ import datetime import pytest -from httpx import Client from scim2_models import Error from scim2_models import Group from scim2_models import ListResponse @@ -10,7 +9,8 @@ from scim2_models import User from scim2_client import RequestNetworkError -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import Client +from scim2_client.engines.httpx2 import SyncSCIMClient def test_all_objects(httpserver): @@ -244,7 +244,7 @@ def test_errors(httpserver, code): def test_request_network_error(httpserver): - """Test that httpx exceptions are transformed in RequestNetworkError.""" + """Test that httpx2 exceptions are transformed in RequestNetworkError.""" client = Client(base_url=f"http://localhost:{httpserver.port}") scim_client = SyncSCIMClient(client, resource_models=(User,)) with pytest.raises( diff --git a/tests/test_utils.py b/tests/test_utils.py index e605db8..5947000 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,7 +8,7 @@ from scim2_models import User from scim2_client import SCIMRequestError -from scim2_client.engines.httpx import SyncSCIMClient +from scim2_client.engines.httpx2 import SyncSCIMClient def test_guess_resource_endpoint(): diff --git a/uv.lock b/uv.lock index 1d99eda..d57bac6 100644 --- a/uv.lock +++ b/uv.lock @@ -522,7 +522,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -560,6 +560,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[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 = "httpx" version = "0.28.1" @@ -575,6 +588,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[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" @@ -752,7 +791,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.11'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ @@ -769,7 +808,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.11'" }, + { 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 = [ @@ -967,12 +1006,12 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" } wheels = [ @@ -989,12 +1028,12 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, + { 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" } @@ -1489,12 +1528,16 @@ dependencies = [ httpx = [ { name = "httpx" }, ] +httpx2 = [ + { name = "httpx2" }, +] werkzeug = [ { name = "werkzeug" }, ] [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "portpicker" }, { name = "prek" }, @@ -1523,13 +1566,15 @@ doc = [ [package.metadata] requires-dist = [ { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.28.0" }, + { name = "httpx2", marker = "extra == 'httpx2'", specifier = ">=2.12" }, { name = "scim2-models", specifier = ">=0.6.12" }, { name = "werkzeug", marker = "extra == 'werkzeug'", specifier = ">=3.1.3" }, ] -provides-extras = ["httpx", "werkzeug"] +provides-extras = ["httpx2", "httpx", "werkzeug"] [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.0" }, { name = "mypy", specifier = ">=1.13.0" }, { name = "portpicker", specifier = ">=1.6.0" }, { name = "prek", specifier = ">=0.1.0" }, @@ -1630,23 +1675,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.11'" }, - { name = "babel", marker = "python_full_version < '3.11'" }, - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "imagesize", marker = "python_full_version < '3.11'" }, - { name = "jinja2", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "requests", marker = "python_full_version < '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" } }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -1661,23 +1706,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version == '3.11.*'" }, - { name = "babel", marker = "python_full_version == '3.11.*'" }, - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "imagesize", marker = "python_full_version == '3.11.*'" }, - { name = "jinja2", marker = "python_full_version == '3.11.*'" }, - { name = "packaging", marker = "python_full_version == '3.11.*'" }, - { name = "pygments", marker = "python_full_version == '3.11.*'" }, - { name = "requests", marker = "python_full_version == '3.11.*'" }, - { name = "roman-numerals", marker = "python_full_version == '3.11.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.11.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.11.*'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { 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" }, ] 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 = [ @@ -1693,23 +1738,23 @@ resolution-markers = [ "python_full_version >= '3.12' and python_full_version < '3.15'", ] dependencies = [ - { 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", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, 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'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" } }, + { 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" }, ] 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 = [ @@ -1724,7 +1769,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/2b/69/b34e0cb5336f09c6866d53b4a19d76c227cdec1bbc7ac4de63ca7d58c9c7/sphinx_design-0.6.1.tar.gz", hash = "sha256:b44eea3719386d04d765c1a8257caca2b3e6f8421d7b3a5e742c0fd45f84e632", size = 2193689, upload-time = "2024-08-02T13:48:44.277Z" } wheels = [ @@ -1741,7 +1786,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { 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" } @@ -1942,6 +1987,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/0a/6dc462e4fb543305283a6157c80f43e3d12ca4702da6ae6521d541c6b55c/tox_uv_bare-1.36.0-py3-none-any.whl", hash = "sha256:ba397dd0396df95a75744d4e42a50ee27207c0ffcf277b62ffba9c3de455a939", size = 22489, upload-time = "2026-07-21T13:09:55.389Z" }, ] +[[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"