Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ 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 [httpx2](https://github.com/pydantic/httpx2) (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 in-process WSGI and ASGI applications (testing), with no dependency. Adaptable to any network engine.
- **Request & Response Validation**: Automatic payload validation against SCIM schemas
- **Error Handling**: Structured exceptions for network, request and response errors

Expand Down
14 changes: 14 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
Changelog
=========

[0.12.0] - Unreleased
---------------------

Added
^^^^^
- :class:`~scim2_client.engines.wsgi.WSGISCIMClient` and
:class:`~scim2_client.engines.asgi.ASGISCIMClient` call a WSGI or an ASGI application directly,
without a network. They only need the standard library, and work with any framework.

Deprecated
^^^^^^^^^^
- :class:`~scim2_client.engines.werkzeug.TestSCIMClient` and the ``werkzeug`` extra. Use
:class:`~scim2_client.engines.wsgi.WSGISCIMClient` instead. Will be removed in 1.0.

[0.11.2] - 2026-10-02
---------------------

Expand Down
5 changes: 5 additions & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@
("py:class", "scim2_client.client.ResourceT"),
("py:class", "scim2_client.engines.httpx2.ResourceT"),
("py:class", "scim2_client.engines.werkzeug.ResourceT"),
("py:class", "scim2_client.engines.wsgi.ResourceT"),
("py:class", "scim2_client.engines.asgi.ResourceT"),
# The httpx2 inventory does not publish its Response class.
("py:class", "httpx2.Response"),
# The WSGI types only exist in typeshed.
("py:class", "WSGIApplication"),
("py:class", "WSGIEnvironment"),
]

# -- Sibling projects ------------------------------------------------------
Expand Down
71 changes: 53 additions & 18 deletions doc/how-to/use-another-engine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,71 @@ Pick a shipped engine
:class:`~scim2_client.engines.httpx2.AsyncSCIMClient`
The same API, awaited. It is the engine an asynchronous application uses.

:class:`~scim2_client.engines.werkzeug.TestSCIMClient`
Takes a WSGI application and executes the server code directly, without a network. This is
faster in a test suite, and an exception raised by the server surfaces in the test rather
than turning into a ``500``.
:class:`~scim2_client.engines.wsgi.WSGISCIMClient`
Calls a WSGI application directly, without a network. This is faster in a test suite, and an
exception raised by the server surfaces in the test rather than turning into a ``500``.

:class:`~scim2_client.engines.asgi.ASGISCIMClient`
The same, awaited, for an ASGI application.

Test a SCIM server without a network
------------------------------------

:class:`~scim2_client.engines.werkzeug.TestSCIMClient` is meant for the authors of SCIM servers.
It takes a :class:`Werkzeug test Client <werkzeug.test.Client>` wrapping the application, and a
prefix when the SCIM endpoints are not served at the root:
:class:`~scim2_client.engines.wsgi.WSGISCIMClient` and
:class:`~scim2_client.engines.asgi.ASGISCIMClient` are meant for the authors of SCIM servers. They
only need the standard library, and work with any WSGI or ASGI application, such as a Flask,
Django or Starlette application, or the applications of
`scim2-server <https://scim2-server.readthedocs.io>`_. Pass the application, and the URL of the
SCIM endpoints when they are not served at the root:

.. code-block:: python
.. tab-set::
:class: outline

from scim2_client.engines.werkzeug import TestSCIMClient
from scim2_models import Group, ScimProvider, User
from werkzeug.test import Client
.. tab-item:: WSGI
:sync: sync

scim = TestSCIMClient(
Client(myapp.create_app()),
scim_prefix="/scim/v2",
provider=ScimProvider(models=[User, Group]),
)
.. code-block:: python

from scim2_client.engines.wsgi import WSGISCIMClient
from scim2_models import Group, ScimProvider, User

user = scim.create(User(user_name="bjensen@example.com"))
assert user.id
scim = WSGISCIMClient(
myapp.create_app(),
base_url="http://localhost/scim/v2",
provider=ScimProvider(models=[User, Group]),
)

user = scim.create(User(user_name="bjensen@example.com"))
assert user.id

.. tab-item:: ASGI
:sync: async

.. code-block:: python

from scim2_client.engines.asgi import ASGISCIMClient
from scim2_models import Group, ScimProvider, User

scim = ASGISCIMClient(
myapp.create_app(),
base_url="http://localhost/scim/v2",
provider=ScimProvider(models=[User, Group]),
)

user = await scim.create(User(user_name="bjensen@example.com"))
assert user.id

The client checks the payloads the application produces, and a compliance mistake fails the
test.

The ``headers`` parameter gives headers to send with every request, such as ``Authorization``.
The ``environ`` parameter of the WSGI engine and the ``scope`` parameter of the ASGI engine add
keys to every request, such as ``REMOTE_USER``. The ASGI engine sends no ``lifespan`` message:
start an application that needs them apart.

:class:`~scim2_client.engines.werkzeug.TestSCIMClient`, which needs Werkzeug, is deprecated in
favor of :class:`~scim2_client.engines.wsgi.WSGISCIMClient`.

Write an engine
---------------

Expand Down
12 changes: 12 additions & 0 deletions doc/reference/engines.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ and inherits the payload handling from the base client.
:members:
:member-order: bysource

.. autoclass:: scim2_client.engines.wsgi.WSGISCIMClient
:members:
:member-order: bysource

.. autoclass:: scim2_client.engines.asgi.ASGISCIMClient
:members:
:member-order: bysource

.. autoclass:: scim2_client.engines.inprocess.InProcessResponse
:members:
:member-order: bysource

.. autoclass:: scim2_client.engines.werkzeug.TestSCIMClient
:members:
:member-order: bysource
175 changes: 175 additions & 0 deletions scim2_client/engines/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import asyncio
from collections.abc import Awaitable
from collections.abc import Callable
from collections.abc import MutableMapping
from typing import Any
from typing import cast
from urllib.parse import unquote
from wsgiref.headers import Headers

from scim2_client.client import BaseAsyncSCIMClient
from scim2_client.client import RawResponse
from scim2_client.engines.inprocess import InProcessResponse
from scim2_client.engines.inprocess import _check_base_url
from scim2_client.engines.inprocess import _default_port
from scim2_client.engines.inprocess import _HeadersT
from scim2_client.engines.inprocess import _InProcessRequest
from scim2_client.engines.inprocess import _prepare_request
from scim2_client.engines.inprocess import _stays_under_base_url

_Scope = MutableMapping[str, Any]
_Message = MutableMapping[str, Any]
_Receive = Callable[[], Awaitable[_Message]]
_Send = Callable[[_Message], Awaitable[None]]
_ASGIApplication = Callable[[_Scope, _Receive, _Send], Awaitable[None]]


def _build_scope(request: _InProcessRequest, extra: _Scope) -> _Scope:
"""Return the ASGI scope of an HTTP request."""
url = request.url
scope: _Scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": request.method,
"scheme": url.scheme,
"path": unquote(url.path),
"raw_path": url.path.encode(),
"query_string": url.query.encode(),
"root_path": "",
"headers": [
(name.lower().encode("latin-1"), value.encode("latin-1"))
for name, value in request.headers
],
"client": ("127.0.0.1", 123),
"server": (url.hostname or "", _default_port(url)),
}
return {**scope, **extra}


async def _call_application(
app: _ASGIApplication, scope: _Scope, body: bytes
) -> InProcessResponse:
"""Call an ASGI application, and return its response once its body is sent."""
started: list[InProcessResponse] = []
chunks: list[bytes] = []
request_sent = False
response_complete = asyncio.Event()

async def receive() -> _Message:
nonlocal request_sent
if not request_sent:
request_sent = True
return {"type": "http.request", "body": body, "more_body": False}

# A request is disconnected only once its response is sent, so an
# application listening for the disconnection, such as a streaming
# response, is not interrupted.
await response_complete.wait()
return {"type": "http.disconnect"}

async def send(message: _Message) -> None:
if message["type"] == "http.response.start":
started[:] = [
InProcessResponse(
status_code=message["status"],
headers=Headers(
[
(name.decode("latin-1"), value.decode("latin-1"))
for name, value in message.get("headers", [])
]
),
)
]
return

if not started:
raise RuntimeError(
"The ASGI application sent a body before starting the response"
)
chunks.append(message.get("body", b""))
if not message.get("more_body", False):
response_complete.set()

await app(scope, receive, send)

if not response_complete.is_set():
raise RuntimeError("The ASGI application returned before sending its response")

response = started[0]
response.content = b"".join(chunks)
return response


class ASGISCIMClient(BaseAsyncSCIMClient):
"""Call an ASGI application directly, without a network.

This is helpful for developers of asynchronous SCIM servers. The server code
runs in the test event loop, so an exception it raises reaches the test
instead of turning into a ``500`` response. It only needs the standard
library, and runs with :mod:`asyncio`.

The ``lifespan`` messages are not sent: an application that needs them
must be started apart.

:param app: The ASGI 3 application.
:param base_url: The absolute URL of the SCIM endpoints, such as ``http://localhost/scim/v2``.
Its path is passed in the ``path`` of the scope, and ``root_path`` is empty.
:param headers: Headers sent with every request.
The headers passed to a request replace those of the same name.
:param scope: Keys added to the ASGI scope of every request,
such as ``state`` or ``user``.
:param provider: The :class:`~scim2_models.ScimProvider` describing the server.
If a request payload describe a resource it does not know, an exception will be raised.
The :class:`~scim2_models.ScimPolicy` it carries rules how much the payloads
exchanged with the server may depart from the specification.
: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_models.SCIMException`
exception will be raised. If :data:`False` the error object is returned. This value can be overwritten in methods.

.. code-block:: python

from scim2_client.engines.asgi import ASGISCIMClient
from scim2_models import Group, ScimProvider, User

scim = ASGISCIMClient(
myapp.create_app(),
base_url="http://localhost/scim/v2",
provider=ScimProvider(models=[User, Group]),
)

user = await scim.create(User(user_name="bjensen@example.com"))
assert user.id
"""

def __init__(
self,
app: _ASGIApplication,
base_url: str = "http://localhost",
headers: _HeadersT | None = None,
scope: _Scope | None = None,
*args: Any,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self.app = app
self.base_url = _check_base_url(base_url)
self.headers = headers
self.scope = scope or {}

def _stays_under_base_url(self, endpoint: str) -> bool:
return _stays_under_base_url(self.base_url, endpoint)

def _parse_json(self, response: RawResponse) -> Any:
return cast(InProcessResponse, response).json()

async def request(self, method: str, url: str, **kwargs: Any) -> InProcessResponse:
request = _prepare_request(self.base_url, self.headers, method, url, **kwargs)
return await _call_application(
self.app, _build_scope(request, self.scope), request.body
)
12 changes: 6 additions & 6 deletions scim2_client/engines/httpx2.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from scim2_client.errors import RequestNetworkException


def stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool:
def _stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool:
"""Tell whether the URL httpx2 builds for an endpoint has the origin and the path prefix of the base URL.

The URL is built as it will be sent, so an absolute endpoint pointing back
Expand All @@ -34,7 +34,7 @@ def stays_under_base_url(client: Client | AsyncClient, endpoint: str) -> bool:


@contextmanager
def handle_request_error() -> Iterator[None]:
def _handle_request_error() -> Iterator[None]:
try:
yield

Expand Down Expand Up @@ -68,13 +68,13 @@ def __init__(self, client: Client, *args: Any, **kwargs: Any) -> None:
self.client = client

def _stays_under_base_url(self, endpoint: str) -> bool:
return stays_under_base_url(self.client, endpoint)
return _stays_under_base_url(self.client, endpoint)

def _parse_json(self, response: RawResponse) -> Any:
return cast(Response, response).json()

def request(self, method: str, url: str, **kwargs: Any) -> Response:
with handle_request_error():
with _handle_request_error():
return self.client.request(method, url, **kwargs)


Expand Down Expand Up @@ -103,11 +103,11 @@ def __init__(self, client: AsyncClient, *args: Any, **kwargs: Any) -> None:
self.client = client

def _stays_under_base_url(self, endpoint: str) -> bool:
return stays_under_base_url(self.client, endpoint)
return _stays_under_base_url(self.client, endpoint)

def _parse_json(self, response: RawResponse) -> Any:
return cast(Response, response).json()

async def request(self, method: str, url: str, **kwargs: Any) -> Response:
with handle_request_error():
with _handle_request_error():
return await self.client.request(method, url, **kwargs)
Loading
Loading