scim2-cli 0.4.1__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-cli
3
- Version: 0.4.1
3
+ Version: 0.6.0
4
4
  Summary: SCIM application development CLI
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop
@@ -217,9 +217,9 @@ Classifier: License :: OSI Approved :: Apache Software License
217
217
  Classifier: Environment :: Web Environment
218
218
  Classifier: Programming Language :: Python
219
219
  Classifier: Operating System :: OS Independent
220
- Requires-Dist: click>=8.1.7
221
- Requires-Dist: scim2-client[httpx2]>=0.11.2
222
- Requires-Dist: scim2-tester>=0.4.0
220
+ Requires-Dist: click>=8.5.0
221
+ Requires-Dist: scim2-client[httpx2]>=0.13.2
222
+ Requires-Dist: scim2-tester>=0.5.1
223
223
  Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.2.0
224
224
  Requires-Dist: pydanclick>=0.5.0
225
225
  Requires-Dist: pygments>=2.18.0
@@ -243,8 +243,9 @@ It allows users and groups creations, modifications and deletions to be synchron
243
243
 
244
244
  ## Features
245
245
 
246
- - **CRUD Commands**: `create`, `query`, `replace` and `delete` resources from the command line
246
+ - **CRUD Commands**: `create`, `query`, `replace`, `modify` and `delete` resources from the command line
247
247
  - **Search Command**: Query resources using SCIM filters, sorting and pagination
248
+ - **Bulk Command**: Send bulk requests passed as JSON through stdin
248
249
  - **Compliance Testing**: Built-in `test` command using [scim2-tester](https://scim2-tester.readthedocs.io) to validate server RFC compliance
249
250
  - **Server Discovery**: Automatic retrieval of server schemas and resource types
250
251
  - **Dynamic CLI Options**: Command options generated from server schemas
@@ -11,8 +11,9 @@ It allows users and groups creations, modifications and deletions to be synchron
11
11
 
12
12
  ## Features
13
13
 
14
- - **CRUD Commands**: `create`, `query`, `replace` and `delete` resources from the command line
14
+ - **CRUD Commands**: `create`, `query`, `replace`, `modify` and `delete` resources from the command line
15
15
  - **Search Command**: Query resources using SCIM filters, sorting and pagination
16
+ - **Bulk Command**: Send bulk requests passed as JSON through stdin
16
17
  - **Compliance Testing**: Built-in `test` command using [scim2-tester](https://scim2-tester.readthedocs.io) to validate server RFC compliance
17
18
  - **Server Discovery**: Automatic retrieval of server schemas and resource types
18
19
  - **Dynamic CLI Options**: Command options generated from server schemas
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.4.1"
7
+ version = "0.6.0"
8
8
  description = "SCIM application development CLI"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -29,9 +29,9 @@ classifiers = [
29
29
  ]
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
- "click>=8.1.7",
33
- "scim2-client[httpx2]>=0.11.2",
34
- "scim2-tester>=0.4.0",
32
+ "click>=8.5.0",
33
+ "scim2-client[httpx2]>=0.13.2",
34
+ "scim2-tester>=0.5.1",
35
35
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
36
36
  "pydanclick>=0.5.0",
37
37
  "pygments>=2.18.0",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.4.1"
7
+ version = "0.6.0"
8
8
  description = "SCIM application development CLI"
9
9
  authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
10
10
  license = {file = "LICENSE.md"}
@@ -26,9 +26,9 @@ classifiers = [
26
26
 
27
27
  requires-python = ">= 3.11"
28
28
  dependencies = [
29
- "click>=8.1.7",
30
- "scim2-client[httpx2]>=0.11.2",
31
- "scim2-tester>=0.4.0",
29
+ "click>=8.5.0",
30
+ "scim2-client[httpx2]>=0.13.2",
31
+ "scim2-tester>=0.5.1",
32
32
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
33
33
  "pydanclick>=0.5.0",
34
34
  "pygments>=2.18.0",
@@ -0,0 +1,88 @@
1
+ from typing import IO
2
+
3
+ import click
4
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
5
+
6
+ from scim2_cli.bulk import bulk_cli
7
+ from scim2_cli.create import create_cli
8
+ from scim2_cli.delete import delete_cli
9
+ from scim2_cli.modify import modify_cli
10
+ from scim2_cli.query import query_cli
11
+ from scim2_cli.replace import replace_cli
12
+ from scim2_cli.search import search_cli
13
+ from scim2_cli.session import Session
14
+ from scim2_cli.test import test_cli
15
+ from scim2_cli.utils import DOC_URL
16
+ from scim2_cli.utils import HeaderType
17
+ from scim2_cli.utils import split_headers
18
+
19
+
20
+ @click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
21
+ @click.option("-u", "--url", help="The SCIM server endpoint.", envvar="SCIM_CLI_URL")
22
+ @click.option(
23
+ "-h",
24
+ "--header",
25
+ multiple=True,
26
+ type=HeaderType(),
27
+ help="Headers to pass in the HTTP requests. Can be passed multiple times. Other users of the machine can see the command line arguments, so pass the secrets with the SCIM_CLI_HEADERS environment variable.",
28
+ envvar="SCIM_CLI_HEADERS",
29
+ )
30
+ @click.option(
31
+ "--no-verify",
32
+ is_flag=True,
33
+ default=False,
34
+ help="Don't perform https certificate verifications.",
35
+ )
36
+ @click.option(
37
+ "-s",
38
+ "--schemas",
39
+ type=click.File(),
40
+ help="Path to a JSON file containing a list of SCIM Schemas. Those schemas will be assumed to be available on the server. If unset, they will be downloaded.",
41
+ envvar="SCIM_CLI_SCHEMAS",
42
+ )
43
+ @click.option(
44
+ "-r",
45
+ "--resource-types",
46
+ type=click.File(),
47
+ help="Path to a JSON file containing a list of SCIM ResourceType. Those resource types will be assumed to be available on the server. If unset, they will be downloaded.",
48
+ envvar="SCIM_CLI_RESOURCE_TYPES",
49
+ )
50
+ @click.option(
51
+ "-c",
52
+ "--service-provider-config",
53
+ type=click.File(),
54
+ help="Path to a JSON file containing the ServiceProviderConfig content of the server. Will be downloaded otherwise.",
55
+ envvar="SCIM_CLI_SERVICE_PROVIDER_CONFIG",
56
+ )
57
+ @click.pass_context
58
+ def cli(
59
+ ctx: click.Context,
60
+ url: str | None,
61
+ header: list[str],
62
+ no_verify: bool,
63
+ schemas: IO[str] | None,
64
+ resource_types: IO[str] | None,
65
+ service_provider_config: IO[str] | None,
66
+ ) -> None:
67
+ """SCIM application development CLI."""
68
+ ctx.obj = Session(
69
+ url=url,
70
+ headers=split_headers(header),
71
+ verify=not no_verify,
72
+ schemas=schemas,
73
+ resource_types=resource_types,
74
+ service_provider_config=service_provider_config,
75
+ )
76
+
77
+
78
+ cli.add_command(create_cli)
79
+ cli.add_command(query_cli)
80
+ cli.add_command(replace_cli)
81
+ cli.add_command(modify_cli)
82
+ cli.add_command(delete_cli)
83
+ cli.add_command(search_cli)
84
+ cli.add_command(bulk_cli)
85
+ cli.add_command(test_cli)
86
+
87
+ if __name__ == "__main__": # pragma: no cover
88
+ cli()
@@ -0,0 +1,36 @@
1
+ import click
2
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
3
+
4
+ from scim2_cli.utils import DOC_URL
5
+ from scim2_cli.utils import SCIM_EXCEPTIONS
6
+ from scim2_cli.utils import echo_response
7
+ from scim2_cli.utils import exception_to_click_error
8
+ from scim2_cli.utils import indent_option
9
+
10
+
11
+ @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="bulk")
12
+ @indent_option()
13
+ @click.pass_context
14
+ def bulk_cli(ctx: click.Context, indent: bool) -> None:
15
+ """Perform a `SCIM bulk <https://www.rfc-editor.org/rfc/rfc7644#section-3.7>`_ request.
16
+
17
+ The bulk request is passed through stdin in JSON format:
18
+
19
+ .. code-block:: bash
20
+
21
+ echo '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], "Operations": [{"method": "POST", "path": "/Users", "bulkId": "qwerty", "data": {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen@example.com"}}]}' | bulk
22
+
23
+ The request is checked against the bulk capabilities of the server before being sent.
24
+ """
25
+ payload = ctx.obj.stdin
26
+ if not payload:
27
+ click.echo(ctx.get_help())
28
+ ctx.exit(1)
29
+
30
+ try:
31
+ response = ctx.obj.client.bulk(payload, raise_scim_errors=False)
32
+
33
+ except SCIM_EXCEPTIONS as scim_exc:
34
+ raise exception_to_click_error(scim_exc) from scim_exc
35
+
36
+ echo_response(response, indent)
@@ -2,59 +2,68 @@ from typing import Any
2
2
  from typing import cast
3
3
 
4
4
  import click
5
- from click import ClickException
6
5
  from pydanclick import from_pydantic
6
+ from scim2_client import Me
7
7
  from scim2_client.engines.httpx2 import SyncSCIMClient
8
8
  from scim2_models import Context
9
9
  from scim2_models import Error
10
10
  from scim2_models import Resource
11
+ from scim2_models import ResourceType
11
12
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
12
13
 
13
14
  from scim2_cli.utils import DOC_URL
14
15
  from scim2_cli.utils import SCIM_EXCEPTIONS
15
16
  from scim2_cli.utils import ModelCommand
17
+ from scim2_cli.utils import command_name
18
+ from scim2_cli.utils import echo_response
16
19
  from scim2_cli.utils import escape_options_help
17
20
  from scim2_cli.utils import exception_to_click_error
18
- from scim2_cli.utils import formatted_payload
21
+ from scim2_cli.utils import indent_option
22
+ from scim2_cli.utils import inherited
23
+ from scim2_cli.utils import me_option
24
+ from scim2_cli.utils import renamed_fields
19
25
  from scim2_cli.utils import unacceptable_fields
20
26
 
21
27
 
22
28
  def create_payload(
23
- client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
29
+ client: SyncSCIMClient,
30
+ resource_type: ResourceType | None,
31
+ payload: Resource[Any] | dict[str, Any],
32
+ indent: bool,
33
+ me: bool,
24
34
  ) -> None:
25
35
  try:
26
36
  # Response payloads are always checked, so the client never returns a dict.
27
37
  response = cast(
28
- "Resource[Any] | Error", client.create(payload, raise_scim_errors=False)
38
+ "Resource[Any] | Error",
39
+ client.create(
40
+ Me if me else resource_type, payload, raise_scim_errors=False
41
+ ),
29
42
  )
30
43
 
31
44
  except SCIM_EXCEPTIONS as scim_exc:
32
45
  raise exception_to_click_error(scim_exc) from scim_exc
33
46
 
34
- click.echo(formatted_payload(response.model_dump(), indent))
47
+ echo_response(response, indent)
35
48
 
36
49
 
37
- def create_factory(model: type[Resource[Any]] | None) -> click.Command:
38
- if not model:
39
- raise ClickException("Invalid model")
40
-
50
+ def create_factory(
51
+ resource_type: ResourceType, model: type[Resource[Any]]
52
+ ) -> click.Command:
41
53
  exclude = unacceptable_fields(Context.RESOURCE_CREATION_REQUEST, model)
42
54
 
43
55
  @click.command(
44
56
  cls=make_rst_to_ansi_formatter(DOC_URL),
45
- name=model.__name__.lower(),
46
- )
47
- @click.option(
48
- "--indent/--no-indent",
49
- is_flag=True,
50
- default=True,
51
- help="Indent JSON response payloads.",
57
+ name=command_name(resource_type),
52
58
  )
53
- @from_pydantic("obj", model, exclude=exclude)
59
+ @indent_option("cli_indent")
60
+ @me_option("cli_me")
61
+ @from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
54
62
  @click.pass_context
55
63
  def create_command(
56
64
  ctx: click.Context,
57
- indent: bool,
65
+ cli_indent: bool,
66
+ cli_me: bool,
58
67
  obj: Resource[Any] | None,
59
68
  *args: Any,
60
69
  **kwargs: Any,
@@ -71,8 +80,8 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
71
80
 
72
81
  .. code-block:: bash
73
82
 
74
- scim create user \\
75
- --user-name "foo" \\
83
+ scim create user \
84
+ --user-name "foo" \
76
85
  --emails '[{"value":"foo@bar.example", "primary": true}, {"value": "foo@baz.example"}]'
77
86
 
78
87
  Input can also be passed through stdin in JSON format:
@@ -81,16 +90,29 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
81
90
 
82
91
  echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create user
83
92
 
93
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
94
+ and the server chooses the resource type:
95
+
96
+ .. code-block:: bash
97
+
98
+ scim create user --me --user-name "foo"
99
+
84
100
  """
85
101
  if obj == model():
86
102
  obj = None
87
103
 
88
- payload = ctx.obj.get("stdin") or obj
104
+ payload = ctx.obj.stdin or obj
89
105
  if not payload:
90
106
  click.echo(ctx.get_help())
91
107
  ctx.exit(1)
92
108
 
93
- create_payload(ctx.obj["client"], payload, indent)
109
+ create_payload(
110
+ ctx.obj.client,
111
+ resource_type,
112
+ payload,
113
+ inherited(ctx, "cli_indent", cli_indent),
114
+ inherited(ctx, "cli_me", cli_me),
115
+ )
94
116
 
95
117
  return escape_options_help(create_command)
96
118
 
@@ -102,16 +124,12 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
102
124
  invoke_without_command=True,
103
125
  )
104
126
  @click.pass_context
105
- @click.option(
106
- "--indent/--no-indent",
107
- is_flag=True,
108
- default=True,
109
- help="Indent JSON response payloads.",
110
- )
111
- def create_cli(ctx: click.Context, indent: bool) -> None:
127
+ @indent_option("cli_indent")
128
+ @me_option("cli_me")
129
+ def create_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
112
130
  """Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
113
131
 
114
- There are subcommands for all the available models, with dynamic attributes.
132
+ There are subcommands for all the resource types of the server, with dynamic attributes.
115
133
  See the attributes for :code:`user` with:
116
134
 
117
135
  .. code-block:: bash
@@ -124,13 +142,16 @@ def create_cli(ctx: click.Context, indent: bool) -> None:
124
142
 
125
143
  echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create
126
144
 
145
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
146
+ and the server chooses the resource type.
147
+
127
148
  """
128
149
  if ctx.invoked_subcommand is not None:
129
150
  return
130
151
 
131
- payload = ctx.obj.get("stdin")
152
+ payload = ctx.obj.stdin
132
153
  if not payload:
133
154
  click.echo(ctx.get_help())
134
155
  ctx.exit(1)
135
156
 
136
- create_payload(ctx.obj["client"], payload, indent)
157
+ create_payload(ctx.obj.client, None, payload, cli_indent, cli_me)
@@ -0,0 +1,59 @@
1
+ import click
2
+ from click import ClickException
3
+ from scim2_client import Me
4
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
5
+
6
+ from scim2_cli.utils import exception_to_click_error
7
+ from scim2_cli.utils import find_target
8
+ from scim2_cli.utils import me_option
9
+
10
+ from .utils import DOC_URL
11
+ from .utils import SCIM_EXCEPTIONS
12
+ from .utils import echo_response
13
+
14
+
15
+ @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="delete")
16
+ @click.argument("resource-type", required=False)
17
+ @click.argument("id", required=False)
18
+ @me_option()
19
+ @click.option(
20
+ "--indent/--no-indent",
21
+ is_flag=True,
22
+ default=True,
23
+ help="Indent JSON response payloads.",
24
+ )
25
+ @click.pass_context
26
+ def delete_cli(
27
+ ctx: click.Context,
28
+ resource_type: str | None,
29
+ id: str | None,
30
+ me: bool,
31
+ indent: bool,
32
+ ) -> None:
33
+ """Perform a `SCIM DELETE query <https://www.rfc-editor.org/rfc/rfc7644#section-3.6>`_ request.
34
+
35
+ .. code-block:: bash
36
+
37
+ delete user 1234
38
+
39
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint:
40
+
41
+ .. code-block:: bash
42
+
43
+ delete --me
44
+ """
45
+ if me and (resource_type or id):
46
+ raise ClickException("--me cannot be used with a resource type or an id.")
47
+
48
+ if not me and not (resource_type and id):
49
+ raise ClickException("Pass a resource type and an id, or --me.")
50
+
51
+ target = find_target(ctx.obj.resource_types, resource_type) if resource_type else Me
52
+
53
+ try:
54
+ response = ctx.obj.client.delete(target, id, raise_scim_errors=False)
55
+
56
+ except SCIM_EXCEPTIONS as scim_exc:
57
+ raise exception_to_click_error(scim_exc) from scim_exc
58
+
59
+ echo_response(response, indent)
@@ -0,0 +1,209 @@
1
+ import json
2
+ from decimal import Decimal
3
+ from typing import Any
4
+
5
+ import click
6
+ from click import ClickException
7
+ from pydantic import ValidationError
8
+ from scim2_client import Me
9
+ from scim2_models import BaseModel
10
+ from scim2_models import Context
11
+ from scim2_models import Error
12
+ from scim2_models import InvalidPathException
13
+ from scim2_models import PatchOp
14
+ from scim2_models import Path
15
+ from scim2_models import Resource
16
+ from scim2_models import SCIMException
17
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
18
+
19
+ from scim2_cli.utils import DOC_URL
20
+ from scim2_cli.utils import SCIM_EXCEPTIONS
21
+ from scim2_cli.utils import echo_response
22
+ from scim2_cli.utils import escape_control_characters
23
+ from scim2_cli.utils import exception_to_click_error
24
+ from scim2_cli.utils import find_target
25
+ from scim2_cli.utils import indent_option
26
+ from scim2_cli.utils import me_option
27
+
28
+ OPERATIONS = {"add": 2, "replace": 2, "remove": 1}
29
+ JSON_TYPES = (bool, int, float, Decimal, BaseModel)
30
+
31
+
32
+ def parse_operations(tokens: tuple[str, ...]) -> list[tuple[str, str, str | None]]:
33
+ """Split the arguments into operations, each one followed by its path and its value."""
34
+ operations: list[tuple[str, str, str | None]] = []
35
+ index = 0
36
+ while index < len(tokens):
37
+ op = tokens[index].lower()
38
+ if op not in OPERATIONS:
39
+ raise ClickException(
40
+ f"Unknown operation '{escape_control_characters(tokens[index])}'. "
41
+ f"Available values are: {', '.join(OPERATIONS)}"
42
+ )
43
+
44
+ nargs = OPERATIONS[op]
45
+ arguments = tokens[index + 1 : index + 1 + nargs]
46
+ if len(arguments) < nargs or (op == "remove" and not arguments[0]):
47
+ expected = "a path and a value" if nargs == 2 else "a path"
48
+ raise ClickException(f"The '{op}' operation takes {expected}.")
49
+
50
+ path, *value = arguments
51
+ operations.append((op, path, value[0] if value else None))
52
+ index += 1 + nargs
53
+
54
+ return operations
55
+
56
+
57
+ def serves(model: type[Resource[Any]], path: str) -> bool:
58
+ """Tell whether a path designates an attribute or an extension of a model."""
59
+ bound = Path[model](path) # type: ignore[valid-type]
60
+ return bound.resolve() is not None or bound.model is not None
61
+
62
+
63
+ def is_json(model: type[Resource[Any]], path: str) -> bool:
64
+ """Tell whether the value of an attribute is written in JSON rather than as a plain string."""
65
+ bound = Path[model](path) # type: ignore[valid-type]
66
+ binding = bound.resolve()
67
+ if binding is None:
68
+ return True
69
+
70
+ if binding.is_multivalued and not binding.sub_field_name and not bound.value_filter:
71
+ return True
72
+
73
+ target = binding.target_type
74
+ return isinstance(target, type) and issubclass(target, JSON_TYPES)
75
+
76
+
77
+ def read_value(model: type[Resource[Any]], path: str, value: str) -> Any:
78
+ """Read a value with the type of the attribute it is meant for."""
79
+ if not is_json(model, path):
80
+ return value
81
+
82
+ try:
83
+ return json.loads(value)
84
+ except json.JSONDecodeError as exc:
85
+ raise ClickException(
86
+ f"Invalid JSON value for '{escape_control_characters(path)}': {exc}"
87
+ ) from exc
88
+
89
+
90
+ def build_patch(
91
+ models: list[type[Resource[Any]]], operations: list[tuple[str, str, str | None]]
92
+ ) -> PatchOp[Resource[Any]]:
93
+ """Build the patch operation of the first model that has all the attributes."""
94
+ paths = [path for _, path, _ in operations]
95
+ for path in paths:
96
+ try:
97
+ Path(path)
98
+ except InvalidPathException as exc:
99
+ raise ClickException(
100
+ f"Invalid path '{escape_control_characters(path)}': {exc}"
101
+ ) from exc
102
+
103
+ model = next(
104
+ (model for model in models if all(serves(model, path) for path in paths)),
105
+ None,
106
+ )
107
+ if model is None:
108
+ unknown = [
109
+ path for path in paths if not any(serves(model, path) for model in models)
110
+ ]
111
+ message = (
112
+ "Unknown attributes: "
113
+ if unknown
114
+ else "No resource type has all the attributes: "
115
+ )
116
+ raise ClickException(
117
+ message
118
+ + ", ".join(escape_control_characters(path) for path in unknown or paths)
119
+ )
120
+
121
+ patch_operations = []
122
+ for op, path, value in operations:
123
+ operation = {"op": op}
124
+ if path:
125
+ operation["path"] = path
126
+ if value is not None:
127
+ operation["value"] = read_value(model, path, value)
128
+ patch_operations.append(operation)
129
+
130
+ try:
131
+ return PatchOp[model].model_validate({"Operations": patch_operations}) # type: ignore[valid-type]
132
+ except ValidationError as exc:
133
+ error = Error.from_validation_errors(exc)[0]
134
+ raise exception_to_click_error(
135
+ SCIMException.from_error(error, scim_ctx=Context.RESOURCE_PATCH_REQUEST)
136
+ ) from exc
137
+
138
+
139
+ @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="modify")
140
+ @click.argument("arguments", nargs=-1, metavar="[RESOURCE_TYPE ID] OPERATION...")
141
+ @me_option()
142
+ @indent_option()
143
+ @click.pass_context
144
+ def modify_cli(
145
+ ctx: click.Context, arguments: tuple[str, ...], me: bool, indent: bool
146
+ ) -> None:
147
+ r"""Perform a `SCIM PATCH <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2>`_ request.
148
+
149
+ The operations follow the resource type and the id. They are applied in order:
150
+
151
+ - :code:`add PATH VALUE` adds a value,
152
+ - :code:`replace PATH VALUE` replaces a value,
153
+ - :code:`remove PATH` removes a value.
154
+
155
+ .. code-block:: bash
156
+
157
+ modify user 1234 replace displayName "Barbara Jensen" \
158
+ remove 'emails[type eq "work"]' \
159
+ add emails '[{"value": "bjensen@example.com", "type": "work"}]'
160
+
161
+ Values of string attributes are passed as is, the other values are passed in JSON.
162
+ An empty path applies a JSON object of attributes to the resource.
163
+ Pass :code:`--` before the operations if a value starts with :code:`-`.
164
+
165
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint:
166
+
167
+ .. code-block:: bash
168
+
169
+ modify --me replace displayName "Barbara Jensen"
170
+
171
+ A patch operation can also be passed through stdin in JSON format:
172
+
173
+ .. code-block:: bash
174
+
175
+ echo '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{"op": "replace", "value": {"displayName": "Barbara Jensen"}}]}' | modify user 1234
176
+
177
+ """
178
+ resource_types = ctx.obj.resource_types
179
+ provider = ctx.obj.client.provider
180
+ if me and arguments and arguments[0].lower() in resource_types:
181
+ raise ClickException("--me cannot be used with a resource type or an id.")
182
+
183
+ if me:
184
+ target, id, tokens = Me, None, arguments
185
+ models = list(
186
+ dict.fromkeys(provider.model_for(rt) for rt in resource_types.values())
187
+ )
188
+ elif len(arguments) < 2:
189
+ raise ClickException("Pass a resource type and an id, or --me.")
190
+ else:
191
+ target = find_target(resource_types, arguments[0])
192
+ id, tokens = arguments[1], arguments[2:]
193
+ models = [provider.model_for(target)]
194
+
195
+ stdin = ctx.obj.stdin
196
+ if stdin and tokens:
197
+ raise ClickException("Pass the operations either as arguments or to stdin.")
198
+
199
+ if not stdin and not tokens:
200
+ raise ClickException("Missing operation.")
201
+
202
+ payload = stdin or build_patch(models, parse_operations(tokens))
203
+ try:
204
+ response = ctx.obj.client.modify(target, id, payload, raise_scim_errors=False)
205
+
206
+ except SCIM_EXCEPTIONS as scim_exc:
207
+ raise exception_to_click_error(scim_exc) from scim_exc
208
+
209
+ echo_response(response, indent)