diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index dadd19f..b38be2a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -31,6 +31,8 @@ jobs: enable-cache: true - name: Install Python ${{ matrix.python }} run: uv python install ${{ matrix.python }} + - name: Install dependencies + run: uv sync --all-extras - name: Run tests run: uv run pytest --showlocals @@ -44,7 +46,7 @@ jobs: with: enable-cache: true - name: Install minimum dependencies - run: uv sync --resolution=lowest-direct + run: uv sync --resolution=lowest-direct --all-extras - name: Run tests run: uv run pytest --showlocals diff --git a/doc/changelog.rst b/doc/changelog.rst index 969e6f3..832f4ac 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -7,12 +7,14 @@ Changelog .. warning:: This version comes with breaking changes: + - `httpx` is no longer a direct dependency, it is shipped in the `httpx` packaging extra. - Use ``scim2_client.engines.httpx.SyncSCIMClient`` instead of ``scim2_client.SCIMClient``. Added ^^^^^ - The `Unknown resource type` request error keeps a reference to the faulty payload. +- New `werkzeug` request engine for application development purpose. Changed ^^^^^^^ diff --git a/doc/conf.py b/doc/conf.py index 9562921..0cf7689 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -42,6 +42,7 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "scim2_models": ("https://scim2-models.readthedocs.io/en/latest/", None), + "werkzeug": ("https://werkzeug.palletsprojects.com", None), } # -- Options for HTML output ---------------------------------------------- diff --git a/doc/reference.rst b/doc/reference.rst index 4551c3c..70742de 100644 --- a/doc/reference.rst +++ b/doc/reference.rst @@ -4,3 +4,11 @@ Reference .. automodule:: scim2_client :members: :member-order: bysource + +.. automodule:: scim2_client.engines.httpx + :members: + :member-order: bysource + +.. automodule:: scim2_client.engines.werkzeug + :members: + :member-order: bysource diff --git a/doc/tutorial.rst b/doc/tutorial.rst index 4dbde04..4afe91f 100644 --- a/doc/tutorial.rst +++ b/doc/tutorial.rst @@ -83,6 +83,19 @@ To achieve this, all the methods provide the following parameters, all are :data which value will excluded from the request payload, and which values are expected in the response payload. +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.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. + +You can easily implement your own engine by inheriting from :class:`~scim2_client.BaseSCIMClient`. + Additional request parameters ============================= diff --git a/pyproject.toml b/pyproject.toml index 362d2f6..55cae05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,10 @@ httpx = [ "httpx>=0.24.0", ] +werkzeug = [ + "werkzeug>=3.1.3", +] + [project.urls] documentation = "https://scim2-client.readthedocs.io" repository = "https://github.com/python-scim/scim2-client" @@ -48,7 +52,9 @@ dev = [ "pytest>=8.2.1", "pytest-coverage>=0.0", "pytest-httpserver>=1.0.10", + "scim2-server >= 0.1.2; python_version>='3.10'", "tox-uv>=1.16.0", + "werkzeug>=3.1.3", ] doc = [ "autodoc-pydantic>=2.2.0", diff --git a/scim2_client/engines/werkzeug.py b/scim2_client/engines/werkzeug.py new file mode 100644 index 0000000..6d848be --- /dev/null +++ b/scim2_client/engines/werkzeug.py @@ -0,0 +1,242 @@ +from contextlib import contextmanager +from typing import Optional +from typing import Union +from urllib.parse import urlencode + +from scim2_models import AnyResource +from scim2_models import Context +from scim2_models import Error +from scim2_models import ListResponse +from scim2_models import Resource +from scim2_models import SearchRequest +from werkzeug.test import Client + +from scim2_client.client import BaseSCIMClient +from scim2_client.errors import SCIMClientError + + +@contextmanager +def handle_response_error(response): + try: + yield + + except SCIMClientError as exc: + exc.source = response + raise exc + + +class TestSCIMClient(BaseSCIMClient): + """A client based on :class:`Werkzeug test Client ` for application development purposes. + + This is helpful for developers of SCIM servers. + This client avoids to perform real HTTP requests and directly execute the server code instead. + This allows to dynamically catch the exceptions if something gets wrong. + + :param client: A WSGI application instance that will be used to send requests. + :param scim_prefix: The scim root endpoint in the application. + :param resource_types: The client resource types. + + .. code-block:: python + + from scim2_client.engines.werkzeug import TestSCIMClient + from scim2_models import User, Group + + testclient = TestSCIMClient(app=scim_provider, resource_types=(User, Group)) + + request_user = User(user_name="foo", display_name="bar") + response_user = scim_client.create(request_user) + assert response_user.user_name == "foo" + """ + + def __init__( + self, + app, + scim_prefix: str = "", + resource_types: Optional[tuple[type[Resource]]] = None, + ): + super().__init__(resource_types=resource_types) + self.client = Client(app) + self.scim_prefix = scim_prefix + + def make_url(self, url: str) -> str: + prefix = ( + self.scim_prefix[:-1] + if self.scim_prefix.endswith("/") + else self.scim_prefix + ) + return f"{prefix}{url}" + + def create( + self, + resource: Union[AnyResource, dict], + check_request_payload: bool = True, + check_response_payload: bool = True, + expected_status_codes: Optional[ + list[int] + ] = BaseSCIMClient.CREATION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool = True, + **kwargs, + ) -> Union[AnyResource, Error, dict]: + url, payload, expected_types, request_kwargs = self.prepare_create_request( + resource=resource, + check_request_payload=check_request_payload, + check_response_payload=check_response_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + response = self.client.post(self.make_url(url), json=payload, **request_kwargs) + + with handle_response_error(payload): + 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, + expected_types=expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_CREATION_RESPONSE, + ) + + def query( + self, + resource_type: Optional[type[Resource]] = None, + id: Optional[str] = None, + search_request: Optional[Union[SearchRequest, dict]] = None, + check_request_payload: bool = True, + check_response_payload: bool = True, + expected_status_codes: Optional[ + list[int] + ] = BaseSCIMClient.QUERY_RESPONSE_STATUS_CODES, + raise_scim_errors: bool = True, + **kwargs, + ): + url, payload, expected_types, request_kwargs = self.prepare_query_request( + resource_type=resource_type, + id=id, + search_request=search_request, + check_request_payload=check_request_payload, + check_response_payload=check_response_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + query_string = urlencode(payload, doseq=False) if payload else None + response = self.client.get( + self.make_url(url), query_string=query_string, **request_kwargs + ) + + with handle_response_error(payload): + 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, + expected_types=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: Optional[SearchRequest] = None, + check_request_payload: bool = True, + check_response_payload: bool = True, + expected_status_codes: Optional[ + list[int] + ] = BaseSCIMClient.SEARCH_RESPONSE_STATUS_CODES, + raise_scim_errors: bool = True, + **kwargs, + ) -> Union[AnyResource, ListResponse[AnyResource], Error, dict]: + url, payload, expected_types, request_kwargs = self.prepare_search_request( + search_request=search_request, + check_request_payload=check_request_payload, + check_response_payload=check_response_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + response = self.client.post(self.make_url(url), json=payload, **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, + expected_types=expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + ) + + def delete( + self, + resource_type: type, + id: str, + check_response_payload: bool = True, + expected_status_codes: Optional[ + list[int] + ] = BaseSCIMClient.DELETION_RESPONSE_STATUS_CODES, + raise_scim_errors: bool = True, + **kwargs, + ) -> Optional[Union[Error, dict]]: + url, request_kwargs = self.prepare_delete_request( + resource_type=resource_type, + id=id, + check_response_payload=check_response_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + response = self.client.delete(self.make_url(url), **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: Union[AnyResource, dict], + check_request_payload: bool = True, + check_response_payload: bool = True, + expected_status_codes: Optional[ + list[int] + ] = BaseSCIMClient.REPLACEMENT_RESPONSE_STATUS_CODES, + raise_scim_errors: bool = True, + **kwargs, + ) -> Union[AnyResource, Error, dict]: + url, payload, expected_types, request_kwargs = self.prepare_replace_request( + resource=resource, + check_request_payload=check_request_payload, + check_response_payload=check_response_payload, + expected_status_codes=expected_status_codes, + raise_scim_errors=raise_scim_errors, + **kwargs, + ) + + response = self.client.put(self.make_url(url), json=payload, **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, + expected_types=expected_types, + check_response_payload=check_response_payload, + raise_scim_errors=raise_scim_errors, + scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, + ) diff --git a/tests/engines/__init__.py b/tests/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/engines/test_werkzeug.py b/tests/engines/test_werkzeug.py new file mode 100644 index 0000000..e43ed82 --- /dev/null +++ b/tests/engines/test_werkzeug.py @@ -0,0 +1,60 @@ +import pytest +from scim2_models import ResourceType +from scim2_models import SearchRequest +from scim2_models import User + +from scim2_client.engines.werkzeug import TestSCIMClient +from scim2_client.errors import SCIMResponseErrorObject + +scim2_server = pytest.importorskip("scim2_server") +from scim2_server.backend import InMemoryBackend # noqa: E402 +from scim2_server.provider import SCIMProvider # noqa: E402 + + +@pytest.fixture +def scim_provider(): + provider = SCIMProvider(InMemoryBackend()) + provider.register_schema(User.to_schema()) + provider.register_resource_type( + ResourceType( + id="User", + name="User", + endpoint="/Users", + schema="urn:ietf:params:scim:schemas:core:2.0:User", + ) + ) + return provider + + +@pytest.fixture +def scim_client(scim_provider): + return TestSCIMClient(app=scim_provider, resource_types=(User,)) + + +def test_werkzeug_engine(scim_client): + request_user = User(user_name="foo", display_name="bar") + response_user = scim_client.create(request_user) + assert response_user.user_name == "foo" + assert response_user.display_name == "bar" + + response_user = scim_client.query(User, response_user.id) + assert response_user.user_name == "foo" + assert response_user.display_name == "bar" + + req = SearchRequest() + response_users = scim_client.search(req) + assert response_users.resources[0].user_name == "foo" + assert response_users.resources[0].display_name == "bar" + + request_user = User(id=response_user.id, user_name="foo", display_name="baz") + response_user = scim_client.replace(request_user) + assert response_user.user_name == "foo" + assert response_user.display_name == "baz" + + response_user = scim_client.query(User, response_user.id) + assert response_user.user_name == "foo" + assert response_user.display_name == "baz" + + scim_client.delete(User, response_user.id) + with pytest.raises(SCIMResponseErrorObject): + scim_client.query(User, response_user.id) diff --git a/uv.lock b/uv.lock index 3eded53..f0a499b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,9 @@ version = 1 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version < '3.10'", + "python_full_version >= '3.10'", +] [[package]] name = "alabaster" @@ -386,7 +390,7 @@ name = "importlib-metadata" version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ @@ -944,10 +948,17 @@ name = "scim2-client" version = "0.2.2" source = { editable = "." } dependencies = [ - { name = "httpx" }, { name = "scim2-models" }, ] +[package.optional-dependencies] +httpx = [ + { name = "httpx" }, +] +werkzeug = [ + { name = "werkzeug" }, +] + [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -955,7 +966,9 @@ dev = [ { name = "pytest" }, { name = "pytest-coverage" }, { name = "pytest-httpserver" }, + { name = "scim2-server", marker = "python_full_version >= '3.10'" }, { name = "tox-uv" }, + { name = "werkzeug" }, ] doc = [ { name = "autodoc-pydantic" }, @@ -967,8 +980,9 @@ doc = [ [package.metadata] requires-dist = [ - { name = "httpx", specifier = ">=0.24.0" }, + { name = "httpx", marker = "extra == 'httpx'", specifier = ">=0.24.0" }, { name = "scim2-models", specifier = ">=0.2.0" }, + { name = "werkzeug", marker = "extra == 'werkzeug'", specifier = ">=3.1.3" }, ] [package.metadata.requires-dev] @@ -978,7 +992,9 @@ dev = [ { name = "pytest", specifier = ">=8.2.1" }, { name = "pytest-coverage", specifier = ">=0.0" }, { name = "pytest-httpserver", specifier = ">=1.0.10" }, + { name = "scim2-server", marker = "python_full_version >= '3.10'", specifier = ">=0.1.2" }, { name = "tox-uv", specifier = ">=1.16.0" }, + { name = "werkzeug", specifier = ">=3.1.3" }, ] doc = [ { name = "autodoc-pydantic", specifier = ">=2.2.0" }, @@ -988,6 +1004,18 @@ doc = [ { name = "sphinx-issues", specifier = ">=5.0.0" }, ] +[[package]] +name = "scim2-filter-parser" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sly", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/c0/2f4d5caee8faa8f0d0979584c4aab95fa69d626d6f9d211dd6bb7089bc2f/scim2_filter_parser-0.7.0.tar.gz", hash = "sha256:1e11dbe2e186fc1be6d93732b467a3bbaa9deff272dfeb3a0540394cfab7030c", size = 21358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/54/b54961bfc5018fa593758c439fe0d4a22fbadfabff49a7559850af9a79e1/scim2_filter_parser-0.7.0-py3-none-any.whl", hash = "sha256:a74f90a2d52a77e0f1bc4d77e84b79f88749469f6f7192d64a4f92e4fe50ab69", size = 23409 }, +] + [[package]] name = "scim2-models" version = "0.2.5" @@ -1000,6 +1028,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bb/64/90f719370837c6f5d377d6b2e7e261a375fb80f5373947805b3c8ede0a83/scim2_models-0.2.5-py3-none-any.whl", hash = "sha256:c4052399003f7c60be1e3f045704fc203fa25de23cf602a004c6f01d24667752", size = 38782 }, ] +[[package]] +name = "scim2-server" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scim2-filter-parser", marker = "python_full_version >= '3.10'" }, + { name = "scim2-models", marker = "python_full_version >= '3.10'" }, + { name = "werkzeug", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/dd/9a0221a5dd2ba6964ffdcfe7817f44295a98bc16e528526f47ca359ee519/scim2_server-0.1.2.tar.gz", hash = "sha256:e8c1d0568d2fa89ffaf62073dc6b47d5fd983bf561fcc3914a41044f0cbff691", size = 80680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/17/c7f49843d63a604130bb6f98aaaa9fdf4847386aac737bd843f89e9b85d2/scim2_server-0.1.2-py3-none-any.whl", hash = "sha256:07cf93294e7d6951fa744d85a6119de6030decaad6794223dd147552bd4fdba0", size = 33060 }, +] + [[package]] name = "shibuya" version = "2024.10.15" @@ -1012,6 +1054,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/11/19d5407d5828e2d6dc45baff6ad813f70f6a10acea9a678db1f41ccee5c5/shibuya-2024.10.15-py3-none-any.whl", hash = "sha256:46d32c4dc7f244bfe130e710f477f4bda64706e5610916089371509992cae5e6", size = 96363 }, ] +[[package]] +name = "sly" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864 }, +] + [[package]] name = "sniffio" version = "1.3.1"