scim2-cli 0.5.0__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.5.0 → scim2_cli-0.6.0}/PKG-INFO +5 -4
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/README.md +2 -1
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/pyproject.toml +3 -3
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/pyproject.toml.orig +3 -3
- 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.5.0 → scim2_cli-0.6.0}/scim2_cli/create.py +8 -8
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/delete.py +4 -15
- scim2_cli-0.6.0/scim2_cli/modify.py +209 -0
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/query.py +6 -7
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/replace.py +9 -9
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/search.py +6 -7
- scim2_cli-0.6.0/scim2_cli/session.py +132 -0
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/test.py +1 -1
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/scim2_cli/utils.py +31 -5
- scim2_cli-0.5.0/scim2_cli/__init__.py +0 -163
- {scim2_cli-0.5.0 → scim2_cli-0.6.0}/LICENSE.md +0 -0
- {scim2_cli-0.5.0 → 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
|
|
@@ -218,8 +218,8 @@ Classifier: Environment :: Web Environment
|
|
|
218
218
|
Classifier: Programming Language :: Python
|
|
219
219
|
Classifier: Operating System :: OS Independent
|
|
220
220
|
Requires-Dist: click>=8.5.0
|
|
221
|
-
Requires-Dist: scim2-client[httpx2]>=0.13.
|
|
222
|
-
Requires-Dist: scim2-tester>=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 = [
|
|
@@ -30,8 +30,8 @@ classifiers = [
|
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
32
|
"click>=8.5.0",
|
|
33
|
-
"scim2-client[httpx2]>=0.13.
|
|
34
|
-
"scim2-tester>=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"}
|
|
@@ -27,8 +27,8 @@ classifiers = [
|
|
|
27
27
|
requires-python = ">= 3.11"
|
|
28
28
|
dependencies = [
|
|
29
29
|
"click>=8.5.0",
|
|
30
|
-
"scim2-client[httpx2]>=0.13.
|
|
31
|
-
"scim2-tester>=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)
|
|
@@ -15,9 +15,9 @@ from scim2_cli.utils import DOC_URL
|
|
|
15
15
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
16
16
|
from scim2_cli.utils import ModelCommand
|
|
17
17
|
from scim2_cli.utils import command_name
|
|
18
|
+
from scim2_cli.utils import echo_response
|
|
18
19
|
from scim2_cli.utils import escape_options_help
|
|
19
20
|
from scim2_cli.utils import exception_to_click_error
|
|
20
|
-
from scim2_cli.utils import formatted_payload
|
|
21
21
|
from scim2_cli.utils import indent_option
|
|
22
22
|
from scim2_cli.utils import inherited
|
|
23
23
|
from scim2_cli.utils import me_option
|
|
@@ -44,7 +44,7 @@ def create_payload(
|
|
|
44
44
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
45
45
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
echo_response(response, indent)
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def create_factory(
|
|
@@ -80,8 +80,8 @@ def create_factory(
|
|
|
80
80
|
|
|
81
81
|
.. code-block:: bash
|
|
82
82
|
|
|
83
|
-
scim create user
|
|
84
|
-
--user-name "foo"
|
|
83
|
+
scim create user \
|
|
84
|
+
--user-name "foo" \
|
|
85
85
|
--emails '[{"value":"foo@bar.example", "primary": true}, {"value": "foo@baz.example"}]'
|
|
86
86
|
|
|
87
87
|
Input can also be passed through stdin in JSON format:
|
|
@@ -101,13 +101,13 @@ def create_factory(
|
|
|
101
101
|
if obj == model():
|
|
102
102
|
obj = None
|
|
103
103
|
|
|
104
|
-
payload = ctx.obj.
|
|
104
|
+
payload = ctx.obj.stdin or obj
|
|
105
105
|
if not payload:
|
|
106
106
|
click.echo(ctx.get_help())
|
|
107
107
|
ctx.exit(1)
|
|
108
108
|
|
|
109
109
|
create_payload(
|
|
110
|
-
ctx.obj
|
|
110
|
+
ctx.obj.client,
|
|
111
111
|
resource_type,
|
|
112
112
|
payload,
|
|
113
113
|
inherited(ctx, "cli_indent", cli_indent),
|
|
@@ -149,9 +149,9 @@ def create_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
|
|
|
149
149
|
if ctx.invoked_subcommand is not None:
|
|
150
150
|
return
|
|
151
151
|
|
|
152
|
-
payload = ctx.obj.
|
|
152
|
+
payload = ctx.obj.stdin
|
|
153
153
|
if not payload:
|
|
154
154
|
click.echo(ctx.get_help())
|
|
155
155
|
ctx.exit(1)
|
|
156
156
|
|
|
157
|
-
create_payload(ctx.obj
|
|
157
|
+
create_payload(ctx.obj.client, None, payload, cli_indent, cli_me)
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import click
|
|
2
2
|
from click import ClickException
|
|
3
3
|
from scim2_client import Me
|
|
4
|
-
from scim2_models import Message
|
|
5
|
-
from scim2_models import Resource
|
|
6
4
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
7
5
|
|
|
8
6
|
from scim2_cli.utils import exception_to_click_error
|
|
@@ -11,7 +9,7 @@ from scim2_cli.utils import me_option
|
|
|
11
9
|
|
|
12
10
|
from .utils import DOC_URL
|
|
13
11
|
from .utils import SCIM_EXCEPTIONS
|
|
14
|
-
from .utils import
|
|
12
|
+
from .utils import echo_response
|
|
15
13
|
|
|
16
14
|
|
|
17
15
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="delete")
|
|
@@ -50,21 +48,12 @@ def delete_cli(
|
|
|
50
48
|
if not me and not (resource_type and id):
|
|
51
49
|
raise ClickException("Pass a resource type and an id, or --me.")
|
|
52
50
|
|
|
53
|
-
target = (
|
|
54
|
-
find_target(ctx.obj["resource_types"], resource_type) if resource_type else Me
|
|
55
|
-
)
|
|
51
|
+
target = find_target(ctx.obj.resource_types, resource_type) if resource_type else Me
|
|
56
52
|
|
|
57
53
|
try:
|
|
58
|
-
response = ctx.obj
|
|
54
|
+
response = ctx.obj.client.delete(target, id, raise_scim_errors=False)
|
|
59
55
|
|
|
60
56
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
61
57
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
payload = (
|
|
65
|
-
response.model_dump()
|
|
66
|
-
if isinstance(response, Resource | Message)
|
|
67
|
-
else response
|
|
68
|
-
)
|
|
69
|
-
payload = formatted_payload(payload, indent)
|
|
70
|
-
click.echo(payload)
|
|
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)
|
|
@@ -17,7 +17,7 @@ from scim2_cli.utils import me_option
|
|
|
17
17
|
|
|
18
18
|
from .utils import DOC_URL
|
|
19
19
|
from .utils import SCIM_EXCEPTIONS
|
|
20
|
-
from .utils import
|
|
20
|
+
from .utils import echo_response
|
|
21
21
|
|
|
22
22
|
DISCOVERY_MODELS = (Schema, ResourceType, ServiceProviderConfig)
|
|
23
23
|
|
|
@@ -106,7 +106,7 @@ def query_cli(
|
|
|
106
106
|
target: ResourceType | type[Resource[Any]] | None = None
|
|
107
107
|
if resource_type:
|
|
108
108
|
targets: dict[str, ResourceType | type[Resource[Any]]] = {
|
|
109
|
-
**ctx.obj
|
|
109
|
+
**ctx.obj.resource_types,
|
|
110
110
|
**{model.__name__.lower(): model for model in DISCOVERY_MODELS},
|
|
111
111
|
}
|
|
112
112
|
target = find_target(targets, resource_type)
|
|
@@ -129,9 +129,9 @@ def query_cli(
|
|
|
129
129
|
f"{', '.join(listing_options)} cannot be used when querying a single resource."
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
-
if ctx.obj.
|
|
132
|
+
if ctx.obj.stdin:
|
|
133
133
|
check_request_payload = False
|
|
134
|
-
payload = ctx.obj.
|
|
134
|
+
payload = ctx.obj.stdin
|
|
135
135
|
|
|
136
136
|
elif single_resource:
|
|
137
137
|
check_request_payload = True
|
|
@@ -158,7 +158,7 @@ def query_cli(
|
|
|
158
158
|
)
|
|
159
159
|
|
|
160
160
|
try:
|
|
161
|
-
response = ctx.obj
|
|
161
|
+
response = ctx.obj.client.query(
|
|
162
162
|
Me if me else target,
|
|
163
163
|
id,
|
|
164
164
|
query_parameters=payload,
|
|
@@ -169,5 +169,4 @@ def query_cli(
|
|
|
169
169
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
170
170
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
171
171
|
|
|
172
|
-
|
|
173
|
-
click.echo(payload)
|
|
172
|
+
echo_response(response, indent)
|
|
@@ -21,7 +21,7 @@ from scim2_cli.utils import me_option
|
|
|
21
21
|
from .utils import DOC_URL
|
|
22
22
|
from .utils import SCIM_EXCEPTIONS
|
|
23
23
|
from .utils import ModelCommand
|
|
24
|
-
from .utils import
|
|
24
|
+
from .utils import echo_response
|
|
25
25
|
from .utils import renamed_fields
|
|
26
26
|
from .utils import unacceptable_fields
|
|
27
27
|
|
|
@@ -45,7 +45,7 @@ def replace_payload(
|
|
|
45
45
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
46
46
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
echo_response(response, indent)
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def replace_factory(
|
|
@@ -82,9 +82,9 @@ def replace_factory(
|
|
|
82
82
|
|
|
83
83
|
.. code-block:: bash
|
|
84
84
|
|
|
85
|
-
replace user
|
|
86
|
-
--id "xxxx-yyyy"
|
|
87
|
-
--user-name "foo"
|
|
85
|
+
replace user \
|
|
86
|
+
--id "xxxx-yyyy" \
|
|
87
|
+
--user-name "foo" \
|
|
88
88
|
--emails '[{"value":"foo@bar.example", "primary": true}, {"value": "foo@baz.example"}]'
|
|
89
89
|
|
|
90
90
|
Input can also be passed through stdin in JSON format:
|
|
@@ -104,13 +104,13 @@ def replace_factory(
|
|
|
104
104
|
if obj == model():
|
|
105
105
|
obj = None
|
|
106
106
|
|
|
107
|
-
payload = ctx.obj.
|
|
107
|
+
payload = ctx.obj.stdin or obj
|
|
108
108
|
if not payload:
|
|
109
109
|
click.echo(ctx.get_help())
|
|
110
110
|
ctx.exit(1)
|
|
111
111
|
|
|
112
112
|
replace_payload(
|
|
113
|
-
ctx.obj
|
|
113
|
+
ctx.obj.client,
|
|
114
114
|
resource_type,
|
|
115
115
|
payload,
|
|
116
116
|
inherited(ctx, "cli_indent", cli_indent),
|
|
@@ -152,9 +152,9 @@ def replace_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
|
|
|
152
152
|
if ctx.invoked_subcommand is not None:
|
|
153
153
|
return
|
|
154
154
|
|
|
155
|
-
payload = ctx.obj.
|
|
155
|
+
payload = ctx.obj.stdin
|
|
156
156
|
if not payload:
|
|
157
157
|
click.echo(ctx.get_help())
|
|
158
158
|
ctx.exit(1)
|
|
159
159
|
|
|
160
|
-
replace_payload(ctx.obj
|
|
160
|
+
replace_payload(ctx.obj.client, None, payload, cli_indent, cli_me)
|
|
@@ -7,7 +7,7 @@ from scim2_cli.utils import find_target
|
|
|
7
7
|
|
|
8
8
|
from .utils import DOC_URL
|
|
9
9
|
from .utils import SCIM_EXCEPTIONS
|
|
10
|
-
from .utils import
|
|
10
|
+
from .utils import echo_response
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="search")
|
|
@@ -80,12 +80,12 @@ def search_cli(
|
|
|
80
80
|
|
|
81
81
|
"""
|
|
82
82
|
target = (
|
|
83
|
-
find_target(ctx.obj
|
|
83
|
+
find_target(ctx.obj.resource_types, resource_type) if resource_type else None
|
|
84
84
|
)
|
|
85
85
|
|
|
86
|
-
if ctx.obj.
|
|
86
|
+
if ctx.obj.stdin:
|
|
87
87
|
check_request_payload = False
|
|
88
|
-
payload = ctx.obj.
|
|
88
|
+
payload = ctx.obj.stdin
|
|
89
89
|
|
|
90
90
|
else:
|
|
91
91
|
check_request_payload = True
|
|
@@ -103,7 +103,7 @@ def search_cli(
|
|
|
103
103
|
)
|
|
104
104
|
|
|
105
105
|
try:
|
|
106
|
-
response = ctx.obj
|
|
106
|
+
response = ctx.obj.client.search(
|
|
107
107
|
target,
|
|
108
108
|
search_request=payload,
|
|
109
109
|
check_request_payload=check_request_payload,
|
|
@@ -113,5 +113,4 @@ def search_cli(
|
|
|
113
113
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
114
114
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
115
115
|
|
|
116
|
-
|
|
117
|
-
click.echo(payload)
|
|
116
|
+
echo_response(response, indent)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from typing import IO
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from click import ClickException
|
|
10
|
+
from httpx2 import Client
|
|
11
|
+
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
12
|
+
from scim2_models import ListResponse
|
|
13
|
+
from scim2_models import Resource
|
|
14
|
+
from scim2_models import ResourceType
|
|
15
|
+
from scim2_models import Schema
|
|
16
|
+
from scim2_models import ScimProvider
|
|
17
|
+
from scim2_models import ScimProviderError
|
|
18
|
+
from scim2_models import ServiceProviderConfig
|
|
19
|
+
|
|
20
|
+
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
21
|
+
from scim2_cli.utils import command_name
|
|
22
|
+
from scim2_cli.utils import escape_control_characters
|
|
23
|
+
from scim2_cli.utils import exception_to_click_error
|
|
24
|
+
|
|
25
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
|
|
29
|
+
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
|
|
30
|
+
payload = json.load(fd)
|
|
31
|
+
if isinstance(payload, dict):
|
|
32
|
+
list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
|
|
33
|
+
model
|
|
34
|
+
].model_validate(payload)
|
|
35
|
+
return list_response.resources or []
|
|
36
|
+
return [model.model_validate(item) for item in payload]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def describe_server(
|
|
40
|
+
scim_client: SyncSCIMClient,
|
|
41
|
+
schemas_fd: IO[str] | None,
|
|
42
|
+
resource_types_fd: IO[str] | None,
|
|
43
|
+
service_provider_config_fd: IO[str] | None,
|
|
44
|
+
) -> ScimProvider:
|
|
45
|
+
"""Describe the server with the configuration files, and query it for the others."""
|
|
46
|
+
resource_types = (
|
|
47
|
+
load_objects(resource_types_fd, ResourceType)
|
|
48
|
+
if resource_types_fd
|
|
49
|
+
else cast(
|
|
50
|
+
"ListResponse[ResourceType]", scim_client.query(ResourceType)
|
|
51
|
+
).resources
|
|
52
|
+
or []
|
|
53
|
+
)
|
|
54
|
+
schemas = (
|
|
55
|
+
load_objects(schemas_fd, Schema)
|
|
56
|
+
if schemas_fd
|
|
57
|
+
else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
|
|
58
|
+
)
|
|
59
|
+
config = (
|
|
60
|
+
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
|
|
61
|
+
if service_provider_config_fd
|
|
62
|
+
else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
|
|
63
|
+
)
|
|
64
|
+
return ScimProvider.from_discovery(schemas, resource_types, config)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Session:
|
|
68
|
+
"""What the commands share: the server, discovered on first use, and the standard input."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
url: str | None,
|
|
73
|
+
headers: dict[str, str],
|
|
74
|
+
verify: bool,
|
|
75
|
+
schemas: IO[str] | None,
|
|
76
|
+
resource_types: IO[str] | None,
|
|
77
|
+
service_provider_config: IO[str] | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.url = url
|
|
80
|
+
self.headers = headers
|
|
81
|
+
self.verify = verify
|
|
82
|
+
self.schemas = schemas
|
|
83
|
+
self.resource_types_file = resource_types
|
|
84
|
+
self.service_provider_config = service_provider_config
|
|
85
|
+
|
|
86
|
+
@cached_property
|
|
87
|
+
def client(self) -> SyncSCIMClient:
|
|
88
|
+
"""The client of the server, described by the configuration files and the discovery."""
|
|
89
|
+
if not self.url:
|
|
90
|
+
raise ClickException("No SCIM server URL defined.")
|
|
91
|
+
|
|
92
|
+
client = SyncSCIMClient(
|
|
93
|
+
Client(base_url=self.url, headers=self.headers, verify=self.verify)
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
client.provider = describe_server(
|
|
97
|
+
client,
|
|
98
|
+
self.schemas,
|
|
99
|
+
self.resource_types_file,
|
|
100
|
+
self.service_provider_config,
|
|
101
|
+
)
|
|
102
|
+
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
|
|
103
|
+
error = exception_to_click_error(exc)
|
|
104
|
+
raise ClickException(
|
|
105
|
+
f"Could not discover the server at {escape_control_characters(self.url)}: "
|
|
106
|
+
f"{error.message}"
|
|
107
|
+
) from exc
|
|
108
|
+
return client
|
|
109
|
+
|
|
110
|
+
@cached_property
|
|
111
|
+
def resource_types(self) -> dict[str, ResourceType]:
|
|
112
|
+
"""The resource types of the server, by command name."""
|
|
113
|
+
return {
|
|
114
|
+
command_name(resource_type): resource_type
|
|
115
|
+
for resource_type in self.client.provider.resource_types
|
|
116
|
+
if resource_type.name or resource_type.id
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@cached_property
|
|
120
|
+
def stdin(self) -> Any:
|
|
121
|
+
"""The JSON payload passed to the standard input, if any."""
|
|
122
|
+
if sys.stdin.isatty(): # pragma: no cover
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
payload = sys.stdin.read().strip()
|
|
126
|
+
if not payload:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(payload)
|
|
131
|
+
except json.JSONDecodeError as exc:
|
|
132
|
+
raise ClickException(f"Invalid JSON input.\n{exc}") from exc
|
|
@@ -12,7 +12,9 @@ from click import ClickException
|
|
|
12
12
|
from click.core import ParameterSource
|
|
13
13
|
from scim2_client import SCIMClientException
|
|
14
14
|
from scim2_models import BaseModel
|
|
15
|
+
from scim2_models import BulkResponse
|
|
15
16
|
from scim2_models import Context
|
|
17
|
+
from scim2_models import Error
|
|
16
18
|
from scim2_models import Mutability
|
|
17
19
|
from scim2_models import Resource
|
|
18
20
|
from scim2_models import ResourceType
|
|
@@ -129,6 +131,30 @@ def formatted_payload(obj: Any, indent: bool) -> str:
|
|
|
129
131
|
return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
|
|
130
132
|
|
|
131
133
|
|
|
134
|
+
def echo_response(response: Any, indent: bool) -> None:
|
|
135
|
+
"""Display the response of the server, and exit with an error code if it reports a failure."""
|
|
136
|
+
if response is None:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
140
|
+
if isinstance(response, Error):
|
|
141
|
+
summary = " ".join(
|
|
142
|
+
str(part) for part in (response.status, response.detail) if part
|
|
143
|
+
)
|
|
144
|
+
click.echo(f"Error: {escape_control_characters(summary)}", err=True)
|
|
145
|
+
click.get_current_context().exit(1)
|
|
146
|
+
|
|
147
|
+
if isinstance(response, BulkResponse):
|
|
148
|
+
operations = response.operations or []
|
|
149
|
+
failed = [op for op in operations if op.status and op.status >= 400]
|
|
150
|
+
if failed:
|
|
151
|
+
click.echo(
|
|
152
|
+
f"Error: {len(failed)} of {len(operations)} bulk operations failed",
|
|
153
|
+
err=True,
|
|
154
|
+
)
|
|
155
|
+
click.get_current_context().exit(1)
|
|
156
|
+
|
|
157
|
+
|
|
132
158
|
def split_headers(headers: list[str]) -> dict[str, str]:
|
|
133
159
|
"""Make a dict from header strings.
|
|
134
160
|
|
|
@@ -155,16 +181,16 @@ class ModelCommand(RstToAnsiGroup):
|
|
|
155
181
|
self.factory = factory
|
|
156
182
|
|
|
157
183
|
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
158
|
-
ctx.ensure_object(dict)
|
|
159
184
|
base = super().list_commands(ctx)
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
if ctx.obj is None:
|
|
186
|
+
return base
|
|
187
|
+
return base + sorted(ctx.obj.resource_types)
|
|
162
188
|
|
|
163
189
|
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
164
|
-
resource_type = ctx.obj
|
|
190
|
+
resource_type = ctx.obj.resource_types.get(cmd_name.lower())
|
|
165
191
|
if resource_type is None:
|
|
166
192
|
return None
|
|
167
|
-
model = ctx.obj
|
|
193
|
+
model = ctx.obj.client.provider.model_for(resource_type)
|
|
168
194
|
return self.factory(resource_type, model)
|
|
169
195
|
|
|
170
196
|
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import sys
|
|
3
|
-
from typing import IO
|
|
4
|
-
from typing import Any
|
|
5
|
-
from typing import TypeVar
|
|
6
|
-
from typing import cast
|
|
7
|
-
|
|
8
|
-
import click
|
|
9
|
-
from httpx2 import Client
|
|
10
|
-
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
11
|
-
from scim2_models import ListResponse
|
|
12
|
-
from scim2_models import Resource
|
|
13
|
-
from scim2_models import ResourceType
|
|
14
|
-
from scim2_models import Schema
|
|
15
|
-
from scim2_models import ScimProvider
|
|
16
|
-
from scim2_models import ScimProviderError
|
|
17
|
-
from scim2_models import ServiceProviderConfig
|
|
18
|
-
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
19
|
-
|
|
20
|
-
from scim2_cli.create import create_cli
|
|
21
|
-
from scim2_cli.delete import delete_cli
|
|
22
|
-
from scim2_cli.query import query_cli
|
|
23
|
-
from scim2_cli.replace import replace_cli
|
|
24
|
-
from scim2_cli.search import search_cli
|
|
25
|
-
from scim2_cli.test import test_cli
|
|
26
|
-
from scim2_cli.utils import DOC_URL
|
|
27
|
-
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
28
|
-
from scim2_cli.utils import HeaderType
|
|
29
|
-
from scim2_cli.utils import command_name
|
|
30
|
-
from scim2_cli.utils import exception_to_click_error
|
|
31
|
-
from scim2_cli.utils import split_headers
|
|
32
|
-
|
|
33
|
-
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
|
|
37
|
-
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
|
|
38
|
-
payload = json.load(fd)
|
|
39
|
-
if isinstance(payload, dict):
|
|
40
|
-
list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
|
|
41
|
-
model
|
|
42
|
-
].model_validate(payload)
|
|
43
|
-
return list_response.resources or []
|
|
44
|
-
return [model.model_validate(item) for item in payload]
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def describe_server(
|
|
48
|
-
scim_client: SyncSCIMClient,
|
|
49
|
-
schemas_fd: IO[str] | None,
|
|
50
|
-
resource_types_fd: IO[str] | None,
|
|
51
|
-
service_provider_config_fd: IO[str] | None,
|
|
52
|
-
) -> ScimProvider:
|
|
53
|
-
"""Describe the server with the configuration files, and query it for the others."""
|
|
54
|
-
resource_types = (
|
|
55
|
-
load_objects(resource_types_fd, ResourceType)
|
|
56
|
-
if resource_types_fd
|
|
57
|
-
else cast(
|
|
58
|
-
"ListResponse[ResourceType]", scim_client.query(ResourceType)
|
|
59
|
-
).resources
|
|
60
|
-
or []
|
|
61
|
-
)
|
|
62
|
-
schemas = (
|
|
63
|
-
load_objects(schemas_fd, Schema)
|
|
64
|
-
if schemas_fd
|
|
65
|
-
else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
|
|
66
|
-
)
|
|
67
|
-
config = (
|
|
68
|
-
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
|
|
69
|
-
if service_provider_config_fd
|
|
70
|
-
else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
|
|
71
|
-
)
|
|
72
|
-
return ScimProvider.from_discovery(schemas, resource_types, config)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
@click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
|
|
76
|
-
@click.option("-u", "--url", help="The SCIM server endpoint.", envvar="SCIM_CLI_URL")
|
|
77
|
-
@click.option(
|
|
78
|
-
"-h",
|
|
79
|
-
"--header",
|
|
80
|
-
multiple=True,
|
|
81
|
-
type=HeaderType(),
|
|
82
|
-
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.",
|
|
83
|
-
envvar="SCIM_CLI_HEADERS",
|
|
84
|
-
)
|
|
85
|
-
@click.option(
|
|
86
|
-
"--no-verify",
|
|
87
|
-
is_flag=True,
|
|
88
|
-
default=False,
|
|
89
|
-
help="Don't perform https certificate verifications.",
|
|
90
|
-
)
|
|
91
|
-
@click.option(
|
|
92
|
-
"-s",
|
|
93
|
-
"--schemas",
|
|
94
|
-
type=click.File(),
|
|
95
|
-
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.",
|
|
96
|
-
envvar="SCIM_CLI_SCHEMAS",
|
|
97
|
-
)
|
|
98
|
-
@click.option(
|
|
99
|
-
"-r",
|
|
100
|
-
"--resource-types",
|
|
101
|
-
type=click.File(),
|
|
102
|
-
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.",
|
|
103
|
-
envvar="SCIM_CLI_RESOURCE_TYPES",
|
|
104
|
-
)
|
|
105
|
-
@click.option(
|
|
106
|
-
"-c",
|
|
107
|
-
"--service-provider-config",
|
|
108
|
-
type=click.File(),
|
|
109
|
-
help="Path to a JSON file containing the ServiceProviderConfig content of the server. Will be downloaded otherwise.",
|
|
110
|
-
envvar="SCIM_CLI_SERVICE_PROVIDER_CONFIG",
|
|
111
|
-
)
|
|
112
|
-
@click.pass_context
|
|
113
|
-
def cli(
|
|
114
|
-
ctx: click.Context,
|
|
115
|
-
url: str | None,
|
|
116
|
-
header: list[str],
|
|
117
|
-
no_verify: bool,
|
|
118
|
-
schemas: IO[str] | None,
|
|
119
|
-
resource_types: IO[str] | None,
|
|
120
|
-
service_provider_config: IO[str] | None,
|
|
121
|
-
) -> None:
|
|
122
|
-
"""SCIM application development CLI."""
|
|
123
|
-
ctx.ensure_object(dict)
|
|
124
|
-
|
|
125
|
-
if not url:
|
|
126
|
-
raise click.ClickException("No SCIM server URL defined.")
|
|
127
|
-
|
|
128
|
-
headers_dict = split_headers(header)
|
|
129
|
-
client = Client(base_url=url, headers=headers_dict, verify=not no_verify)
|
|
130
|
-
|
|
131
|
-
scim_client = SyncSCIMClient(client)
|
|
132
|
-
try:
|
|
133
|
-
scim_client.provider = describe_server(
|
|
134
|
-
scim_client, schemas, resource_types, service_provider_config
|
|
135
|
-
)
|
|
136
|
-
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
|
|
137
|
-
raise exception_to_click_error(exc) from exc
|
|
138
|
-
|
|
139
|
-
ctx.obj["client"] = scim_client
|
|
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
|
-
}
|
|
145
|
-
|
|
146
|
-
if not sys.stdin.isatty(): # pragma: no cover
|
|
147
|
-
if stdin := sys.stdin.read().strip():
|
|
148
|
-
try:
|
|
149
|
-
ctx.obj["stdin"] = json.loads(stdin)
|
|
150
|
-
except json.JSONDecodeError as exc:
|
|
151
|
-
message = f"Invalid JSON input.\n{exc}"
|
|
152
|
-
raise click.ClickException(message) from exc
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
cli.add_command(create_cli)
|
|
156
|
-
cli.add_command(query_cli)
|
|
157
|
-
cli.add_command(replace_cli)
|
|
158
|
-
cli.add_command(delete_cli)
|
|
159
|
-
cli.add_command(search_cli)
|
|
160
|
-
cli.add_command(test_cli)
|
|
161
|
-
|
|
162
|
-
if __name__ == "__main__": # pragma: no cover
|
|
163
|
-
cli()
|
|
File without changes
|
|
File without changes
|