scim2-cli 0.4.1__tar.gz → 0.5.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.5.0}/PKG-INFO +3 -3
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/pyproject.toml +3 -3
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/pyproject.toml.orig +3 -3
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/__init__.py +6 -9
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/create.py +47 -26
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/delete.py +29 -11
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/query.py +33 -17
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/replace.py +43 -30
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/search.py +20 -3
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/utils.py +78 -6
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/LICENSE.md +0 -0
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/README.md +0 -0
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/py.typed +0 -0
- {scim2_cli-0.4.1 → scim2_cli-0.5.0}/scim2_cli/test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: SCIM application development CLI
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop
|
|
@@ -217,8 +217,8 @@ 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.
|
|
220
|
+
Requires-Dist: click>=8.5.0
|
|
221
|
+
Requires-Dist: scim2-client[httpx2]>=0.13.0
|
|
222
222
|
Requires-Dist: scim2-tester>=0.4.0
|
|
223
223
|
Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.2.0
|
|
224
224
|
Requires-Dist: pydanclick>=0.5.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.5.0"
|
|
8
8
|
description = "SCIM application development CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -29,8 +29,8 @@ classifiers = [
|
|
|
29
29
|
]
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"click>=8.
|
|
33
|
-
"scim2-client[httpx2]>=0.
|
|
32
|
+
"click>=8.5.0",
|
|
33
|
+
"scim2-client[httpx2]>=0.13.0",
|
|
34
34
|
"scim2-tester>=0.4.0",
|
|
35
35
|
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
36
36
|
"pydanclick>=0.5.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.5.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,8 +26,8 @@ classifiers = [
|
|
|
26
26
|
|
|
27
27
|
requires-python = ">= 3.11"
|
|
28
28
|
dependencies = [
|
|
29
|
-
"click>=8.
|
|
30
|
-
"scim2-client[httpx2]>=0.
|
|
29
|
+
"click>=8.5.0",
|
|
30
|
+
"scim2-client[httpx2]>=0.13.0",
|
|
31
31
|
"scim2-tester>=0.4.0",
|
|
32
32
|
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
33
33
|
"pydanclick>=0.5.0",
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import re
|
|
3
2
|
import sys
|
|
4
3
|
from typing import IO
|
|
5
4
|
from typing import Any
|
|
@@ -27,7 +26,7 @@ from scim2_cli.test import test_cli
|
|
|
27
26
|
from scim2_cli.utils import DOC_URL
|
|
28
27
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
29
28
|
from scim2_cli.utils import HeaderType
|
|
30
|
-
from scim2_cli.utils import
|
|
29
|
+
from scim2_cli.utils import command_name
|
|
31
30
|
from scim2_cli.utils import exception_to_click_error
|
|
32
31
|
from scim2_cli.utils import split_headers
|
|
33
32
|
|
|
@@ -137,14 +136,12 @@ def cli(
|
|
|
137
136
|
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
|
|
138
137
|
raise exception_to_click_error(exc) from exc
|
|
139
138
|
|
|
140
|
-
provider = scim_client.provider
|
|
141
139
|
ctx.obj["client"] = scim_client
|
|
142
|
-
ctx.obj["
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
ctx.obj["resource_models"][escape_control_characters(name)] = resource_model
|
|
140
|
+
ctx.obj["resource_types"] = {
|
|
141
|
+
command_name(resource_type): resource_type
|
|
142
|
+
for resource_type in scim_client.provider.resource_types
|
|
143
|
+
if resource_type.name or resource_type.id
|
|
144
|
+
}
|
|
148
145
|
|
|
149
146
|
if not sys.stdin.isatty(): # pragma: no cover
|
|
150
147
|
if stdin := sys.stdin.read().strip():
|
|
@@ -2,30 +2,43 @@ 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
|
|
16
18
|
from scim2_cli.utils import escape_options_help
|
|
17
19
|
from scim2_cli.utils import exception_to_click_error
|
|
18
20
|
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,
|
|
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:
|
|
@@ -34,27 +47,23 @@ def create_payload(
|
|
|
34
47
|
click.echo(formatted_payload(response.model_dump(), 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,
|
|
@@ -81,6 +90,13 @@ 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
|
|
@@ -90,7 +106,13 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
|
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,6 +142,9 @@ 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
|
|
@@ -133,4 +154,4 @@ def create_cli(ctx: click.Context, indent: bool) -> None:
|
|
|
133
154
|
click.echo(ctx.get_help())
|
|
134
155
|
ctx.exit(1)
|
|
135
156
|
|
|
136
|
-
create_payload(ctx.obj["client"], payload,
|
|
157
|
+
create_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import click
|
|
2
2
|
from click import ClickException
|
|
3
|
+
from scim2_client import Me
|
|
3
4
|
from scim2_models import Message
|
|
4
5
|
from scim2_models import Resource
|
|
5
6
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
6
7
|
|
|
7
8
|
from scim2_cli.utils import exception_to_click_error
|
|
9
|
+
from scim2_cli.utils import find_target
|
|
10
|
+
from scim2_cli.utils import me_option
|
|
8
11
|
|
|
9
12
|
from .utils import DOC_URL
|
|
10
13
|
from .utils import SCIM_EXCEPTIONS
|
|
@@ -12,8 +15,9 @@ from .utils import formatted_payload
|
|
|
12
15
|
|
|
13
16
|
|
|
14
17
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="delete")
|
|
15
|
-
@click.argument("resource-type", required=
|
|
16
|
-
@click.argument("id", required=
|
|
18
|
+
@click.argument("resource-type", required=False)
|
|
19
|
+
@click.argument("id", required=False)
|
|
20
|
+
@me_option()
|
|
17
21
|
@click.option(
|
|
18
22
|
"--indent/--no-indent",
|
|
19
23
|
is_flag=True,
|
|
@@ -21,23 +25,37 @@ from .utils import formatted_payload
|
|
|
21
25
|
help="Indent JSON response payloads.",
|
|
22
26
|
)
|
|
23
27
|
@click.pass_context
|
|
24
|
-
def delete_cli(
|
|
28
|
+
def delete_cli(
|
|
29
|
+
ctx: click.Context,
|
|
30
|
+
resource_type: str | None,
|
|
31
|
+
id: str | None,
|
|
32
|
+
me: bool,
|
|
33
|
+
indent: bool,
|
|
34
|
+
) -> None:
|
|
25
35
|
"""Perform a `SCIM DELETE query <https://www.rfc-editor.org/rfc/rfc7644#section-3.6>`_ request.
|
|
26
36
|
|
|
27
37
|
.. code-block:: bash
|
|
28
38
|
|
|
29
39
|
delete user 1234
|
|
40
|
+
|
|
41
|
+
With :code:`--me`, the request is made on the :code:`/Me` endpoint:
|
|
42
|
+
|
|
43
|
+
.. code-block:: bash
|
|
44
|
+
|
|
45
|
+
delete --me
|
|
30
46
|
"""
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
raise ClickException(
|
|
36
|
-
|
|
37
|
-
|
|
47
|
+
if me and (resource_type or id):
|
|
48
|
+
raise ClickException("--me cannot be used with a resource type or an id.")
|
|
49
|
+
|
|
50
|
+
if not me and not (resource_type and id):
|
|
51
|
+
raise ClickException("Pass a resource type and an id, or --me.")
|
|
52
|
+
|
|
53
|
+
target = (
|
|
54
|
+
find_target(ctx.obj["resource_types"], resource_type) if resource_type else Me
|
|
55
|
+
)
|
|
38
56
|
|
|
39
57
|
try:
|
|
40
|
-
response = ctx.obj["client"].delete(
|
|
58
|
+
response = ctx.obj["client"].delete(target, id, raise_scim_errors=False)
|
|
41
59
|
|
|
42
60
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
43
61
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
1
3
|
import click
|
|
2
4
|
from click import ClickException
|
|
5
|
+
from scim2_client import Me
|
|
6
|
+
from scim2_models import Resource
|
|
3
7
|
from scim2_models import ResourceType
|
|
4
8
|
from scim2_models import ResponseParameters
|
|
5
9
|
from scim2_models import Schema
|
|
@@ -8,11 +12,15 @@ from scim2_models import ServiceProviderConfig
|
|
|
8
12
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
9
13
|
|
|
10
14
|
from scim2_cli.utils import exception_to_click_error
|
|
15
|
+
from scim2_cli.utils import find_target
|
|
16
|
+
from scim2_cli.utils import me_option
|
|
11
17
|
|
|
12
18
|
from .utils import DOC_URL
|
|
13
19
|
from .utils import SCIM_EXCEPTIONS
|
|
14
20
|
from .utils import formatted_payload
|
|
15
21
|
|
|
22
|
+
DISCOVERY_MODELS = (Schema, ResourceType, ServiceProviderConfig)
|
|
23
|
+
|
|
16
24
|
|
|
17
25
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="query")
|
|
18
26
|
@click.pass_context
|
|
@@ -33,6 +41,10 @@ from .utils import formatted_payload
|
|
|
33
41
|
type=int,
|
|
34
42
|
help="An integer indicating the 1-based index of the first query result.",
|
|
35
43
|
)
|
|
44
|
+
@click.option(
|
|
45
|
+
"--cursor",
|
|
46
|
+
help="The cursor of the page to read, for cursor-based pagination (RFC 9865). Pass an empty value to read the first page.",
|
|
47
|
+
)
|
|
36
48
|
@click.option(
|
|
37
49
|
"--count",
|
|
38
50
|
type=int,
|
|
@@ -49,6 +61,7 @@ from .utils import formatted_payload
|
|
|
49
61
|
"--sort-order",
|
|
50
62
|
help="A string indicating the order in which the “sortBy” parameter is applied.",
|
|
51
63
|
)
|
|
64
|
+
@me_option()
|
|
52
65
|
@click.option(
|
|
53
66
|
"--indent/--no-indent",
|
|
54
67
|
is_flag=True,
|
|
@@ -62,10 +75,12 @@ def query_cli(
|
|
|
62
75
|
attribute: list[str],
|
|
63
76
|
excluded_attribute: list[str],
|
|
64
77
|
start_index: int | None,
|
|
78
|
+
cursor: str | None,
|
|
65
79
|
count: int | None,
|
|
66
80
|
filter: str | None,
|
|
67
81
|
sort_by: str | None,
|
|
68
82
|
sort_order: str | None,
|
|
83
|
+
me: bool,
|
|
69
84
|
indent: bool,
|
|
70
85
|
) -> None:
|
|
71
86
|
"""Perform a `SCIM GET <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_ request on the :code:`RESOURCE_TYPE` endpoint.
|
|
@@ -73,6 +88,7 @@ def query_cli(
|
|
|
73
88
|
- If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is `1234`, then the request will made on the :code:`/Users/1234` endpoint.
|
|
74
89
|
- If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is not set, then the request will made on the :code:`/Users` endpoint.
|
|
75
90
|
- If :code:`RESOURCE_TYPE` is not set, then the request will made on the :code:`/` endpoint.
|
|
91
|
+
- If :code:`--me` is set, then the request will be made on the :code:`/Me` endpoint.
|
|
76
92
|
|
|
77
93
|
When a single resource is queried, only :code:`--attribute` and :code:`--excluded-attribute` are
|
|
78
94
|
available, as defined in `RFC7644 §3.4.1 <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_.
|
|
@@ -84,24 +100,23 @@ def query_cli(
|
|
|
84
100
|
echo '{"startIndex": 50, "count": 10}' | query user
|
|
85
101
|
|
|
86
102
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
if me and (resource_type or id):
|
|
104
|
+
raise ClickException("--me cannot be used with a resource type or an id.")
|
|
89
105
|
|
|
90
|
-
|
|
106
|
+
target: ResourceType | type[Resource[Any]] | None = None
|
|
91
107
|
if resource_type:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
single_resource = bool(id) or model is ServiceProviderConfig
|
|
108
|
+
targets: dict[str, ResourceType | type[Resource[Any]]] = {
|
|
109
|
+
**ctx.obj["resource_types"],
|
|
110
|
+
**{model.__name__.lower(): model for model in DISCOVERY_MODELS},
|
|
111
|
+
}
|
|
112
|
+
target = find_target(targets, resource_type)
|
|
113
|
+
|
|
114
|
+
single_resource = me or bool(id) or target is ServiceProviderConfig
|
|
101
115
|
listing_options = [
|
|
102
116
|
name
|
|
103
117
|
for name, value in (
|
|
104
118
|
("--start-index", start_index),
|
|
119
|
+
("--cursor", cursor),
|
|
105
120
|
("--count", count),
|
|
106
121
|
("--filter", filter),
|
|
107
122
|
("--sort-by", sort_by),
|
|
@@ -122,8 +137,8 @@ def query_cli(
|
|
|
122
137
|
check_request_payload = True
|
|
123
138
|
payload = ResponseParameters.model_validate(
|
|
124
139
|
{
|
|
125
|
-
"attributes": attribute,
|
|
126
|
-
"excluded_attributes": excluded_attribute,
|
|
140
|
+
"attributes": attribute or None,
|
|
141
|
+
"excluded_attributes": excluded_attribute or None,
|
|
127
142
|
}
|
|
128
143
|
)
|
|
129
144
|
|
|
@@ -131,9 +146,10 @@ def query_cli(
|
|
|
131
146
|
check_request_payload = True
|
|
132
147
|
payload = SearchRequest.model_validate(
|
|
133
148
|
{
|
|
134
|
-
"attributes": attribute,
|
|
135
|
-
"excluded_attributes": excluded_attribute,
|
|
149
|
+
"attributes": attribute or None,
|
|
150
|
+
"excluded_attributes": excluded_attribute or None,
|
|
136
151
|
"start_index": start_index,
|
|
152
|
+
"cursor": cursor,
|
|
137
153
|
"count": count,
|
|
138
154
|
"filter": filter,
|
|
139
155
|
"sort_by": sort_by,
|
|
@@ -143,7 +159,7 @@ def query_cli(
|
|
|
143
159
|
|
|
144
160
|
try:
|
|
145
161
|
response = ctx.obj["client"].query(
|
|
146
|
-
|
|
162
|
+
Me if me else target,
|
|
147
163
|
id,
|
|
148
164
|
query_parameters=payload,
|
|
149
165
|
check_request_payload=check_request_payload,
|
|
@@ -2,31 +2,44 @@ 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
|
|
|
14
|
+
from scim2_cli.utils import command_name
|
|
13
15
|
from scim2_cli.utils import escape_options_help
|
|
14
16
|
from scim2_cli.utils import exception_to_click_error
|
|
17
|
+
from scim2_cli.utils import indent_option
|
|
18
|
+
from scim2_cli.utils import inherited
|
|
19
|
+
from scim2_cli.utils import me_option
|
|
15
20
|
|
|
16
21
|
from .utils import DOC_URL
|
|
17
22
|
from .utils import SCIM_EXCEPTIONS
|
|
18
23
|
from .utils import ModelCommand
|
|
19
24
|
from .utils import formatted_payload
|
|
25
|
+
from .utils import renamed_fields
|
|
20
26
|
from .utils import unacceptable_fields
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
def replace_payload(
|
|
24
|
-
client: SyncSCIMClient,
|
|
30
|
+
client: SyncSCIMClient,
|
|
31
|
+
resource_type: ResourceType | None,
|
|
32
|
+
payload: Resource[Any] | dict[str, Any],
|
|
33
|
+
indent: bool,
|
|
34
|
+
me: bool,
|
|
25
35
|
) -> None:
|
|
26
36
|
try:
|
|
27
37
|
# Response payloads are always checked, so the client never returns a dict.
|
|
28
38
|
response = cast(
|
|
29
|
-
"Resource[Any] | Error",
|
|
39
|
+
"Resource[Any] | Error",
|
|
40
|
+
client.replace(
|
|
41
|
+
Me if me else resource_type, payload, raise_scim_errors=False
|
|
42
|
+
),
|
|
30
43
|
)
|
|
31
44
|
|
|
32
45
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
@@ -35,28 +48,24 @@ def replace_payload(
|
|
|
35
48
|
click.echo(formatted_payload(response.model_dump(), indent))
|
|
36
49
|
|
|
37
50
|
|
|
38
|
-
def replace_factory(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
def replace_factory(
|
|
52
|
+
resource_type: ResourceType, model: type[Resource[Any]]
|
|
53
|
+
) -> click.Command:
|
|
42
54
|
exclude = unacceptable_fields(Context.RESOURCE_REPLACEMENT_REQUEST, model)
|
|
43
55
|
exclude.remove("id")
|
|
44
56
|
|
|
45
57
|
@click.command(
|
|
46
58
|
cls=make_rst_to_ansi_formatter(DOC_URL),
|
|
47
|
-
name=
|
|
48
|
-
)
|
|
49
|
-
@click.option(
|
|
50
|
-
"--indent/--no-indent",
|
|
51
|
-
is_flag=True,
|
|
52
|
-
default=True,
|
|
53
|
-
help="Indent JSON response payloads.",
|
|
59
|
+
name=command_name(resource_type),
|
|
54
60
|
)
|
|
55
|
-
@
|
|
61
|
+
@indent_option("cli_indent")
|
|
62
|
+
@me_option("cli_me")
|
|
63
|
+
@from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
|
|
56
64
|
@click.pass_context
|
|
57
65
|
def replace_command(
|
|
58
66
|
ctx: click.Context,
|
|
59
|
-
|
|
67
|
+
cli_indent: bool,
|
|
68
|
+
cli_me: bool,
|
|
60
69
|
obj: Resource[Any] | None,
|
|
61
70
|
*args: Any,
|
|
62
71
|
**kwargs: Any,
|
|
@@ -84,6 +93,13 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
|
84
93
|
|
|
85
94
|
echo '{"id": "xxxx-yyyy", "userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | replace user
|
|
86
95
|
|
|
96
|
+
With :code:`--me`, the request is made on the :code:`/Me` endpoint,
|
|
97
|
+
and :code:`--id` is not needed:
|
|
98
|
+
|
|
99
|
+
.. code-block:: bash
|
|
100
|
+
|
|
101
|
+
scim replace user --me --user-name "foo"
|
|
102
|
+
|
|
87
103
|
"""
|
|
88
104
|
if obj == model():
|
|
89
105
|
obj = None
|
|
@@ -95,8 +111,10 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
|
95
111
|
|
|
96
112
|
replace_payload(
|
|
97
113
|
ctx.obj["client"],
|
|
114
|
+
resource_type,
|
|
98
115
|
payload,
|
|
99
|
-
|
|
116
|
+
inherited(ctx, "cli_indent", cli_indent),
|
|
117
|
+
inherited(ctx, "cli_me", cli_me),
|
|
100
118
|
)
|
|
101
119
|
|
|
102
120
|
return escape_options_help(replace_command)
|
|
@@ -109,16 +127,12 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
|
109
127
|
invoke_without_command=True,
|
|
110
128
|
)
|
|
111
129
|
@click.pass_context
|
|
112
|
-
@
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
default=True,
|
|
116
|
-
help="Indent JSON response payloads.",
|
|
117
|
-
)
|
|
118
|
-
def replace_cli(ctx: click.Context, indent: bool) -> None:
|
|
130
|
+
@indent_option("cli_indent")
|
|
131
|
+
@me_option("cli_me")
|
|
132
|
+
def replace_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
|
|
119
133
|
"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.1>`_ request on the resources endpoint.
|
|
120
134
|
|
|
121
|
-
There are subcommands for all the
|
|
135
|
+
There are subcommands for all the resource types of the server, with dynamic attributes.
|
|
122
136
|
See the attributes for :code:`user` with:
|
|
123
137
|
|
|
124
138
|
.. code-block:: bash
|
|
@@ -131,6 +145,9 @@ def replace_cli(ctx: click.Context, indent: bool) -> None:
|
|
|
131
145
|
|
|
132
146
|
echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "1234"}' | replace user
|
|
133
147
|
|
|
148
|
+
With :code:`--me`, the request is made on the :code:`/Me` endpoint,
|
|
149
|
+
and the id is not needed.
|
|
150
|
+
|
|
134
151
|
"""
|
|
135
152
|
if ctx.invoked_subcommand is not None:
|
|
136
153
|
return
|
|
@@ -140,8 +157,4 @@ def replace_cli(ctx: click.Context, indent: bool) -> None:
|
|
|
140
157
|
click.echo(ctx.get_help())
|
|
141
158
|
ctx.exit(1)
|
|
142
159
|
|
|
143
|
-
replace_payload(
|
|
144
|
-
ctx.obj["client"],
|
|
145
|
-
payload,
|
|
146
|
-
indent,
|
|
147
|
-
)
|
|
160
|
+
replace_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
|
|
@@ -3,6 +3,7 @@ from scim2_models import SearchRequest
|
|
|
3
3
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
4
4
|
|
|
5
5
|
from scim2_cli.utils import exception_to_click_error
|
|
6
|
+
from scim2_cli.utils import find_target
|
|
6
7
|
|
|
7
8
|
from .utils import DOC_URL
|
|
8
9
|
from .utils import SCIM_EXCEPTIONS
|
|
@@ -11,6 +12,7 @@ from .utils import formatted_payload
|
|
|
11
12
|
|
|
12
13
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="search")
|
|
13
14
|
@click.pass_context
|
|
15
|
+
@click.argument("resource_type", required=False)
|
|
14
16
|
@click.option(
|
|
15
17
|
"--attribute",
|
|
16
18
|
multiple=True,
|
|
@@ -26,6 +28,10 @@ from .utils import formatted_payload
|
|
|
26
28
|
type=int,
|
|
27
29
|
help="An integer indicating the 1-based index of the first query result.",
|
|
28
30
|
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--cursor",
|
|
33
|
+
help="The cursor of the page to read, for cursor-based pagination (RFC 9865). Pass an empty value to read the first page.",
|
|
34
|
+
)
|
|
29
35
|
@click.option(
|
|
30
36
|
"--count",
|
|
31
37
|
type=int,
|
|
@@ -50,16 +56,21 @@ from .utils import formatted_payload
|
|
|
50
56
|
)
|
|
51
57
|
def search_cli(
|
|
52
58
|
ctx: click.Context,
|
|
59
|
+
resource_type: str | None,
|
|
53
60
|
attribute: list[str],
|
|
54
61
|
excluded_attribute: list[str],
|
|
55
62
|
start_index: int | None,
|
|
63
|
+
cursor: str | None,
|
|
56
64
|
count: int | None,
|
|
57
65
|
filter: str | None,
|
|
58
66
|
sort_by: str | None,
|
|
59
67
|
sort_order: str | None,
|
|
60
68
|
indent: bool,
|
|
61
69
|
) -> None:
|
|
62
|
-
"""Perform a `SCIM
|
|
70
|
+
"""Perform a `SCIM POST search <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.3>`_ request.
|
|
71
|
+
|
|
72
|
+
- If :code:`RESOURCE_TYPE` is :code:`user`, then the request will be made on the :code:`/Users/.search` endpoint.
|
|
73
|
+
- If :code:`RESOURCE_TYPE` is not set, then the request will be made on the :code:`/.search` endpoint.
|
|
63
74
|
|
|
64
75
|
Data passed in JSON format to stdin is sent as request arguments and all the other query arguments are ignored:
|
|
65
76
|
|
|
@@ -68,6 +79,10 @@ def search_cli(
|
|
|
68
79
|
echo '{"startIndex": 50, "count": 10}' | search user
|
|
69
80
|
|
|
70
81
|
"""
|
|
82
|
+
target = (
|
|
83
|
+
find_target(ctx.obj["resource_types"], resource_type) if resource_type else None
|
|
84
|
+
)
|
|
85
|
+
|
|
71
86
|
if ctx.obj.get("stdin"):
|
|
72
87
|
check_request_payload = False
|
|
73
88
|
payload = ctx.obj.get("stdin")
|
|
@@ -76,9 +91,10 @@ def search_cli(
|
|
|
76
91
|
check_request_payload = True
|
|
77
92
|
payload = SearchRequest.model_validate(
|
|
78
93
|
{
|
|
79
|
-
"attributes": attribute,
|
|
80
|
-
"excluded_attributes": excluded_attribute,
|
|
94
|
+
"attributes": attribute or None,
|
|
95
|
+
"excluded_attributes": excluded_attribute or None,
|
|
81
96
|
"start_index": start_index,
|
|
97
|
+
"cursor": cursor,
|
|
82
98
|
"count": count,
|
|
83
99
|
"filter": filter,
|
|
84
100
|
"sort_by": sort_by,
|
|
@@ -88,6 +104,7 @@ def search_cli(
|
|
|
88
104
|
|
|
89
105
|
try:
|
|
90
106
|
response = ctx.obj["client"].search(
|
|
107
|
+
target,
|
|
91
108
|
search_request=payload,
|
|
92
109
|
check_request_payload=check_request_payload,
|
|
93
110
|
raise_scim_errors=False,
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import re
|
|
3
3
|
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Mapping
|
|
4
5
|
from enum import StrEnum
|
|
5
6
|
from typing import Any
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
from typing import cast
|
|
6
9
|
|
|
7
10
|
import click
|
|
11
|
+
from click import ClickException
|
|
12
|
+
from click.core import ParameterSource
|
|
8
13
|
from scim2_client import SCIMClientException
|
|
9
14
|
from scim2_models import BaseModel
|
|
10
15
|
from scim2_models import Context
|
|
11
16
|
from scim2_models import Mutability
|
|
12
17
|
from scim2_models import Resource
|
|
18
|
+
from scim2_models import ResourceType
|
|
13
19
|
from scim2_models import SCIMException
|
|
14
20
|
from sphinx_click.rst_to_ansi_formatter import RstToAnsiGroup
|
|
15
21
|
|
|
@@ -18,6 +24,34 @@ INDENTATION_SIZE = 4
|
|
|
18
24
|
|
|
19
25
|
SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)
|
|
20
26
|
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
29
|
+
|
|
30
|
+
COMMAND_OPTIONS = {"me", "indent", "no-indent", "help"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def me_option(*param_decls: str) -> Callable[[F], F]:
|
|
34
|
+
"""Add the --me option, with an optional parameter name."""
|
|
35
|
+
return click.option(
|
|
36
|
+
"--me",
|
|
37
|
+
*param_decls,
|
|
38
|
+
is_flag=True,
|
|
39
|
+
default=False,
|
|
40
|
+
help="Act on the resource of the authenticated client, under /Me (RFC 7644 §3.11).",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def indent_option(*param_decls: str) -> Callable[[F], F]:
|
|
45
|
+
"""Add the --indent option, with an optional parameter name."""
|
|
46
|
+
return click.option(
|
|
47
|
+
"--indent/--no-indent",
|
|
48
|
+
*param_decls,
|
|
49
|
+
is_flag=True,
|
|
50
|
+
default=True,
|
|
51
|
+
help="Indent JSON response payloads.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
21
55
|
CONTROL_CHARACTERS = re.compile(
|
|
22
56
|
"[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"
|
|
23
57
|
)
|
|
@@ -66,6 +100,31 @@ def escape_options_help(command: click.Command) -> click.Command:
|
|
|
66
100
|
return command
|
|
67
101
|
|
|
68
102
|
|
|
103
|
+
def command_name(resource_type: ResourceType) -> str:
|
|
104
|
+
"""Name the commands acting on a resource type after its name, or else its id."""
|
|
105
|
+
return escape_control_characters(resource_type.name or resource_type.id).lower()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def find_target(targets: Mapping[str, T], name: str) -> T:
|
|
109
|
+
"""Find what a resource type argument designates, ignoring the case."""
|
|
110
|
+
try:
|
|
111
|
+
return targets[name.lower()]
|
|
112
|
+
except KeyError as exc:
|
|
113
|
+
ok_values = ", ".join(targets)
|
|
114
|
+
raise ClickException(
|
|
115
|
+
f"Unknown resource type '{escape_control_characters(name)}'. "
|
|
116
|
+
f"Available values are: {ok_values}"
|
|
117
|
+
) from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def inherited(ctx: click.Context, name: str, value: T) -> T:
|
|
121
|
+
"""Read an option of a subcommand, or else the one passed to its parent command."""
|
|
122
|
+
parent = ctx.parent
|
|
123
|
+
if parent is None or ctx.get_parameter_source(name) is not ParameterSource.DEFAULT:
|
|
124
|
+
return value
|
|
125
|
+
return cast(T, parent.params[name])
|
|
126
|
+
|
|
127
|
+
|
|
69
128
|
def formatted_payload(obj: Any, indent: bool) -> str:
|
|
70
129
|
return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
|
|
71
130
|
|
|
@@ -82,14 +141,14 @@ def split_headers(headers: list[str]) -> dict[str, str]:
|
|
|
82
141
|
|
|
83
142
|
|
|
84
143
|
class ModelCommand(RstToAnsiGroup):
|
|
85
|
-
"""CLI commands that
|
|
144
|
+
"""CLI commands that take a subcommand for each resource type."""
|
|
86
145
|
|
|
87
146
|
base_url = DOC_URL
|
|
88
147
|
|
|
89
148
|
def __init__(
|
|
90
149
|
self,
|
|
91
150
|
*args: Any,
|
|
92
|
-
factory: Callable[[type[Resource[Any]]
|
|
151
|
+
factory: Callable[[ResourceType, type[Resource[Any]]], click.Command],
|
|
93
152
|
**kwargs: Any,
|
|
94
153
|
) -> None:
|
|
95
154
|
super().__init__(*args, **kwargs)
|
|
@@ -98,12 +157,15 @@ class ModelCommand(RstToAnsiGroup):
|
|
|
98
157
|
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
99
158
|
ctx.ensure_object(dict)
|
|
100
159
|
base = super().list_commands(ctx)
|
|
101
|
-
lazy = sorted(ctx.obj.get("
|
|
160
|
+
lazy = sorted(ctx.obj.get("resource_types", {}).keys())
|
|
102
161
|
return base + lazy
|
|
103
162
|
|
|
104
|
-
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
|
|
105
|
-
|
|
106
|
-
|
|
163
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
164
|
+
resource_type = ctx.obj["resource_types"].get(cmd_name.lower())
|
|
165
|
+
if resource_type is None:
|
|
166
|
+
return None
|
|
167
|
+
model = ctx.obj["client"].provider.model_for(resource_type)
|
|
168
|
+
return self.factory(resource_type, model)
|
|
107
169
|
|
|
108
170
|
|
|
109
171
|
def is_field_acceptable(
|
|
@@ -144,6 +206,16 @@ def unacceptable_fields(context: Context, model: type[BaseModel]) -> list[str]:
|
|
|
144
206
|
return excluded
|
|
145
207
|
|
|
146
208
|
|
|
209
|
+
def renamed_fields(model: type[BaseModel], exclude: list[str]) -> dict[str, str]:
|
|
210
|
+
"""Rename the options of the attributes that collide with the options of the command."""
|
|
211
|
+
return {
|
|
212
|
+
field_name: f"--{option}-attribute"
|
|
213
|
+
for field_name in model.model_fields
|
|
214
|
+
if field_name not in exclude
|
|
215
|
+
and (option := field_name.replace("_", "-")) in COMMAND_OPTIONS
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
147
219
|
def exception_to_click_error(exception: Exception) -> click.ClickException:
|
|
148
220
|
message = str(exception)
|
|
149
221
|
if hasattr(exception, "__notes__"):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|