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.
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/PKG-INFO +6 -5
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/README.md +2 -1
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/pyproject.toml +4 -4
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/pyproject.toml.orig +4 -4
- scim2_cli-0.6.0/scim2_cli/__init__.py +88 -0
- scim2_cli-0.6.0/scim2_cli/bulk.py +36 -0
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/create.py +53 -32
- scim2_cli-0.6.0/scim2_cli/delete.py +59 -0
- scim2_cli-0.6.0/scim2_cli/modify.py +209 -0
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/query.py +38 -23
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/replace.py +51 -38
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/search.py +25 -9
- scim2_cli-0.6.0/scim2_cli/session.py +132 -0
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/test.py +1 -1
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/utils.py +106 -8
- scim2_cli-0.4.1/scim2_cli/__init__.py +0 -166
- scim2_cli-0.4.1/scim2_cli/delete.py +0 -52
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/LICENSE.md +0 -0
- {scim2_cli-0.4.1 → scim2_cli-0.6.0}/scim2_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-cli
|
|
3
|
-
Version: 0.
|
|
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.
|
|
221
|
-
Requires-Dist: scim2-client[httpx2]>=0.
|
|
222
|
-
Requires-Dist: scim2-tester>=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.
|
|
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.
|
|
33
|
-
"scim2-client[httpx2]>=0.
|
|
34
|
-
"scim2-tester>=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.
|
|
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.
|
|
30
|
-
"scim2-client[httpx2]>=0.
|
|
31
|
-
"scim2-tester>=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
|
|
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,
|
|
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",
|
|
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
|
-
|
|
47
|
+
echo_response(response, indent)
|
|
35
48
|
|
|
36
49
|
|
|
37
|
-
def create_factory(
|
|
38
|
-
|
|
39
|
-
|
|
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=
|
|
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
|
-
@
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
@
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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.
|
|
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
|
|
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)
|