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
21 changes: 21 additions & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
Changelog
=========

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

Changed
^^^^^^^
- scim2-client 0.8.0 and scim2-tester 0.3.0 are now the minimum supported versions.
- Requests are performed with `httpx2 <https://github.com/pydantic/httpx2>`_ instead of
httpx, following the scim2-client 0.8 engine rename.
- :ref:`query` only sends the ``attributes`` and ``excludedAttributes`` parameters when a
single resource is queried, as :rfc:`RFC7644 §3.4.1 <7644#section-3.4.1>` defines those
as the sole parameters of that request. ``--start-index``, ``--count``, ``--filter``,
``--sort-by`` and ``--sort-order`` are refused in that case, instead of being sent along.

Fixed
^^^^^
- Server SCIM errors and invalid request payloads are reported as readable messages
instead of a traceback. scim2-client 0.8 raises the scim2-models exceptions for those,
which do not belong to its own exception hierarchy.
- The :ref:`test` ``--dont-check-status-code`` and ``--dont-check-content-type`` options
were not applied on the client.

[0.2.4] - 2026-01-25
--------------------

Expand Down
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
pygments_style = "sphinx"
todo_include_todos = True
toctree_collapse = False
suppress_warnings = ["autosectionlabel.changelog"]

intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ classifiers = [
requires-python = ">= 3.10"
dependencies = [
"click>=8.1.7",
"scim2-client>=0.7.1",
"scim2-tester[httpx]>=0.2.5",
"scim2-client[httpx2]>=0.8.0",
"scim2-tester>=0.3.0",
"sphinx-click-rst-to-ansi-formatter>=0.1.0",
"pydanclick>=0.4.0",
"pygments>=2.18.0",
Expand Down
8 changes: 4 additions & 4 deletions scim2_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
import re

import click
from httpx import Client
from scim2_client import SCIMClientError
from scim2_client.engines.httpx import SyncSCIMClient
from httpx2 import Client
from scim2_client.engines.httpx2 import SyncSCIMClient
from scim2_models import Group
from scim2_models import ListResponse
from scim2_models import Resource
Expand All @@ -21,6 +20,7 @@
from scim2_cli.search import search_cli
from scim2_cli.test import test_cli
from scim2_cli.utils import DOC_URL
from scim2_cli.utils import SCIM_EXCEPTIONS
from scim2_cli.utils import HeaderType
from scim2_cli.utils import exception_to_click_error
from scim2_cli.utils import split_headers
Expand Down Expand Up @@ -138,7 +138,7 @@ def cli(
resource_types=not bool(resource_types),
service_provider_config=not bool(service_provider_config),
)
except SCIMClientError as exc:
except SCIM_EXCEPTIONS as exc:
raise exception_to_click_error(exc) from exc

ctx.obj["client"] = scim_client
Expand Down
4 changes: 2 additions & 2 deletions scim2_cli/create.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import click
from click import ClickException
from pydanclick import from_pydantic
from scim2_client import SCIMClientError
from scim2_models import Context
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter

from scim2_cli.utils import DOC_URL
from scim2_cli.utils import SCIM_EXCEPTIONS
from scim2_cli.utils import ModelCommand
from scim2_cli.utils import exception_to_click_error
from scim2_cli.utils import formatted_payload
Expand All @@ -16,7 +16,7 @@ def create_payload(client, payload, indent):
try:
response = client.create(payload, raise_scim_errors=False)

except SCIMClientError as scim_exc:
except SCIM_EXCEPTIONS as scim_exc:
raise exception_to_click_error(scim_exc) from scim_exc

payload = formatted_payload(response.model_dump(), indent)
Expand Down
4 changes: 2 additions & 2 deletions scim2_cli/delete.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import click
from click import ClickException
from scim2_client import SCIMClientError
from scim2_models import Message
from scim2_models import Resource
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter

from scim2_cli.utils import exception_to_click_error

from .utils import DOC_URL
from .utils import SCIM_EXCEPTIONS
from .utils import formatted_payload


Expand Down Expand Up @@ -39,7 +39,7 @@ def delete_cli(ctx, resource_type, id, indent):
try:
response = ctx.obj["client"].delete(resource_model, id, raise_scim_errors=False)

except SCIMClientError as scim_exc:
except SCIM_EXCEPTIONS as scim_exc:
raise exception_to_click_error(scim_exc) from scim_exc

if response:
Expand Down
35 changes: 32 additions & 3 deletions scim2_cli/query.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import click
from click import ClickException
from scim2_client import SCIMClientError
from scim2_models import ResourceType
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import SearchRequest
from scim2_models import ServiceProviderConfig
Expand All @@ -10,6 +10,7 @@
from scim2_cli.utils import exception_to_click_error

from .utils import DOC_URL
from .utils import SCIM_EXCEPTIONS
from .utils import formatted_payload


Expand Down Expand Up @@ -73,6 +74,9 @@ def query_cli(
- If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is not set, then the request will made on the :code:`/Users` endpoint.
- If :code:`RESOURCE_TYPE` is not set, then the request will made on the :code:`/` endpoint.

When a single resource is queried, only :code:`--attribute` and :code:`--excluded-attribute` are
available, as defined in `RFC7644 §3.4.1 <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_.

Data passed in JSON format to stdin is sent as request arguments and all the other query arguments are ignored:

.. code-block:: bash
Expand All @@ -92,10 +96,35 @@ def query_cli(
f"Unknown resource type '{resource_type}. Available values are: {ok_values}'"
) from exc

# ServiceProviderConfig is a singleton endpoint, so it is reached without an id.
single_resource = bool(id) or resource_type is ServiceProviderConfig
listing_options = [
name
for name, value in (
("--start-index", start_index),
("--count", count),
("--filter", filter),
("--sort-by", sort_by),
("--sort-order", sort_order),
)
if value is not None
]
if single_resource and listing_options:
raise ClickException(
f"{', '.join(listing_options)} cannot be used when querying a single resource."
)

if ctx.obj.get("stdin"):
check_request_payload = False
payload = ctx.obj.get("stdin")

elif single_resource:
check_request_payload = True
payload = ResponseParameters(
attributes=attribute,
excluded_attributes=excluded_attribute,
)

else:
check_request_payload = True
payload = SearchRequest(
Expand All @@ -112,12 +141,12 @@ def query_cli(
response = ctx.obj["client"].query(
resource_type,
id,
search_request=payload,
query_parameters=payload,
check_request_payload=check_request_payload,
raise_scim_errors=False,
)

except SCIMClientError as scim_exc:
except SCIM_EXCEPTIONS as scim_exc:
raise exception_to_click_error(scim_exc) from scim_exc

payload = formatted_payload(response.model_dump(), indent)
Expand Down
4 changes: 2 additions & 2 deletions scim2_cli/replace.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import click
from click import ClickException
from pydanclick import from_pydantic
from scim2_client import SCIMClientError
from scim2_models import Context
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter

from scim2_cli.utils import exception_to_click_error

from .utils import DOC_URL
from .utils import SCIM_EXCEPTIONS
from .utils import ModelCommand
from .utils import formatted_payload
from .utils import unacceptable_fields
Expand All @@ -17,7 +17,7 @@ def replace_payload(client, payload, indent):
try:
response = client.replace(payload, raise_scim_errors=False)

except SCIMClientError as scim_exc:
except SCIM_EXCEPTIONS as scim_exc:
raise exception_to_click_error(scim_exc) from scim_exc

payload = formatted_payload(response.model_dump(), indent)
Expand Down
4 changes: 2 additions & 2 deletions scim2_cli/search.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import click
from scim2_client import SCIMClientError
from scim2_models import SearchRequest
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter

from scim2_cli.utils import exception_to_click_error

from .utils import DOC_URL
from .utils import SCIM_EXCEPTIONS
from .utils import formatted_payload


Expand Down Expand Up @@ -91,7 +91,7 @@ def search_cli(
raise_scim_errors=False,
)

except SCIMClientError as scim_exc:
except SCIM_EXCEPTIONS as scim_exc:
raise exception_to_click_error(scim_exc) from scim_exc

payload = formatted_payload(response.model_dump(), indent)
Expand Down
4 changes: 2 additions & 2 deletions scim2_cli/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ def test_cli(ctx, verbose, check_status_code, check_content_type):
test
"""
client = ctx.obj["client"]
client.check_status_code = check_status_code
client.check_content_type = check_content_type
client.check_response_status_codes = check_status_code
client.check_response_content_type = check_content_type
results = check_server(client)
click.echo(f"Performing a SCIM compliance check on {client.client.base_url} ...")
success = True
Expand Down
7 changes: 7 additions & 0 deletions scim2_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
from enum import Enum

import click
from scim2_client import SCIMClientException
from scim2_models import SCIMException
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter

DOC_URL = "https://scim2-cli.readthedocs.io/"
INDENTATION_SIZE = 4

# scim2-client raises its own exceptions for transport and response issues, and
# scim2-models SCIM exceptions for invalid request payloads and server errors.
# The two hierarchies are unrelated, so both must be caught.
SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)


class HeaderType(click.ParamType):
envvar_list_splitter = ";"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from scim2_models import AuthenticationScheme
from scim2_models import Bulk
from scim2_models import ChangePassword
from scim2_models import Error
from scim2_models import ETag
from scim2_models import Filter
from scim2_models import ListResponse
Expand Down Expand Up @@ -325,3 +326,21 @@ def test_custom_configuration_by_env(
del os.environ["SCIM_CLI_SERVICE_PROVIDER_CONFIG"]
del os.environ["SCIM_CLI_SCHEMAS"]
del os.environ["SCIM_CLI_RESOURCE_TYPES"]


def test_discovery_scim_error(runner, httpserver):
"""Test that the discovery step displays a readable error when the server answers a SCIM error."""
httpserver.clear_all_handlers()
httpserver.expect_request("/ResourceTypes").respond_with_json(
Error(status=403, detail="Insufficient permissions").model_dump(),
status=403,
content_type="application/scim+json",
)

result = runner.invoke(
cli,
["--url", httpserver.url_for("/"), "query"],
catch_exceptions=False,
)
assert result.exit_code == 1, result.output
assert "Error: Insufficient permissions" in result.output
19 changes: 19 additions & 0 deletions tests/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,22 @@ def test_command_validation_error(runner, httpserver, simple_user_payload):
)
assert result.exit_code == 1, result.output
assert "Expected type User but got undefined object with no schema" in result.output


def test_no_command_unknown_schema(runner, httpserver):
"""Test that a payload with an unknown schema displays a readable error."""
payload = {
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:Unknown",
],
"userName": "new-user@example.com",
}

result = runner.invoke(
cli,
["--url", httpserver.url_for("/"), "create"],
input=json.dumps(payload),
catch_exceptions=False,
)
assert result.exit_code == 1, result.output
assert "Cannot guess resource type from the payload" in result.output
Loading
Loading