scim2-cli 0.4.0__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.0 → scim2_cli-0.5.0}/PKG-INFO +4 -4
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/pyproject.toml +13 -5
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/pyproject.toml.orig +14 -6
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/__init__.py +37 -23
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/create.py +65 -28
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/delete.py +29 -11
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/query.py +51 -30
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/replace.py +61 -32
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/search.py +35 -16
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/scim2_cli/test.py +3 -1
- scim2_cli-0.5.0/scim2_cli/utils.py +223 -0
- scim2_cli-0.4.0/scim2_cli/utils.py +0 -143
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/LICENSE.md +0 -0
- {scim2_cli-0.4.0 → scim2_cli-0.5.0}/README.md +0 -0
- {scim2_cli-0.4.0 → scim2_cli-0.5.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.5.0
|
|
4
4
|
Summary: SCIM application development CLI
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop
|
|
@@ -217,10 +217,10 @@ 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
|
-
Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.
|
|
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
|
|
226
226
|
Requires-Python: >=3.11
|
|
@@ -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,10 +29,10 @@ 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
|
-
"sphinx-click-rst-to-ansi-formatter>=0.
|
|
35
|
+
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
36
36
|
"pydanclick>=0.5.0",
|
|
37
37
|
"pygments>=2.18.0",
|
|
38
38
|
]
|
|
@@ -56,7 +56,7 @@ scim2 = "scim2_cli:cli"
|
|
|
56
56
|
|
|
57
57
|
[dependency-groups]
|
|
58
58
|
dev = [
|
|
59
|
-
"mypy>=
|
|
59
|
+
"mypy>=2.3.1",
|
|
60
60
|
"prek>=0.1.0",
|
|
61
61
|
"pytest>=8.2.1",
|
|
62
62
|
"pytest-cov>=6.0.0",
|
|
@@ -85,6 +85,7 @@ scim2-server = false
|
|
|
85
85
|
scim2-cli = false
|
|
86
86
|
scim2-tester = false
|
|
87
87
|
pytest-scim2-server = false
|
|
88
|
+
sphinx-click-rst-to-ansi-formatter = false
|
|
88
89
|
|
|
89
90
|
[tool.uv.build-backend]
|
|
90
91
|
module-root = ""
|
|
@@ -104,6 +105,7 @@ exclude_lines = [
|
|
|
104
105
|
"raise NotImplementedError",
|
|
105
106
|
"except ImportError",
|
|
106
107
|
"if app.debug",
|
|
108
|
+
"if TYPE_CHECKING:",
|
|
107
109
|
]
|
|
108
110
|
|
|
109
111
|
[tool.ruff.lint]
|
|
@@ -141,6 +143,12 @@ ignore-words-list = "datas"
|
|
|
141
143
|
|
|
142
144
|
[tool.mypy]
|
|
143
145
|
plugins = ["pydantic.mypy"]
|
|
146
|
+
files = ["scim2_cli"]
|
|
147
|
+
strict = true
|
|
148
|
+
|
|
149
|
+
[tool.pydantic-mypy]
|
|
150
|
+
init_forbid_extra = true
|
|
151
|
+
init_typed = true
|
|
144
152
|
|
|
145
153
|
[tool.pytest.ini_options]
|
|
146
154
|
testpaths = ["tests"]
|
|
@@ -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,10 +26,10 @@ 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
|
-
"sphinx-click-rst-to-ansi-formatter>=0.
|
|
32
|
+
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
33
33
|
"pydanclick>=0.5.0",
|
|
34
34
|
"pygments>=2.18.0",
|
|
35
35
|
]
|
|
@@ -42,7 +42,7 @@ funding = "https://github.com/sponsors/python-scim"
|
|
|
42
42
|
|
|
43
43
|
[dependency-groups]
|
|
44
44
|
dev = [
|
|
45
|
-
"mypy>=
|
|
45
|
+
"mypy>=2.3.1",
|
|
46
46
|
"prek>=0.1.0",
|
|
47
47
|
"pytest>=8.2.1",
|
|
48
48
|
"pytest-cov>=6.0.0",
|
|
@@ -69,7 +69,8 @@ scim2 = "scim2_cli:cli"
|
|
|
69
69
|
|
|
70
70
|
[tool.uv]
|
|
71
71
|
exclude-newer = "14 days"
|
|
72
|
-
|
|
72
|
+
# TODO: remove sphinx-click-rst-to-ansi-formatter after 2026-10-20, when 0.2.0 is older than 14 days.
|
|
73
|
+
exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-server = false, scim2-cli = false, scim2-tester = false, pytest-scim2-server = false, sphinx-click-rst-to-ansi-formatter = false }
|
|
73
74
|
|
|
74
75
|
[tool.uv.build-backend]
|
|
75
76
|
module-root = ""
|
|
@@ -89,6 +90,7 @@ exclude_lines = [
|
|
|
89
90
|
"raise NotImplementedError",
|
|
90
91
|
"except ImportError",
|
|
91
92
|
"if app.debug",
|
|
93
|
+
"if TYPE_CHECKING:",
|
|
92
94
|
]
|
|
93
95
|
|
|
94
96
|
[tool.ruff.lint]
|
|
@@ -128,6 +130,12 @@ ignore-words-list = "datas"
|
|
|
128
130
|
plugins = [
|
|
129
131
|
"pydantic.mypy"
|
|
130
132
|
]
|
|
133
|
+
files = ["scim2_cli"]
|
|
134
|
+
strict = true
|
|
135
|
+
|
|
136
|
+
[tool.pydantic-mypy]
|
|
137
|
+
init_forbid_extra = true
|
|
138
|
+
init_typed = true
|
|
131
139
|
|
|
132
140
|
[tool.pytest.ini_options]
|
|
133
141
|
testpaths = ["tests"]
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import
|
|
2
|
+
import sys
|
|
3
|
+
from typing import IO
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
from typing import cast
|
|
3
7
|
|
|
4
8
|
import click
|
|
5
9
|
from httpx2 import Client
|
|
6
10
|
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
7
11
|
from scim2_models import ListResponse
|
|
12
|
+
from scim2_models import Resource
|
|
8
13
|
from scim2_models import ResourceType
|
|
9
14
|
from scim2_models import Schema
|
|
10
15
|
from scim2_models import ScimProvider
|
|
@@ -21,37 +26,48 @@ from scim2_cli.test import test_cli
|
|
|
21
26
|
from scim2_cli.utils import DOC_URL
|
|
22
27
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
23
28
|
from scim2_cli.utils import HeaderType
|
|
24
|
-
from scim2_cli.utils import
|
|
29
|
+
from scim2_cli.utils import command_name
|
|
25
30
|
from scim2_cli.utils import exception_to_click_error
|
|
26
31
|
from scim2_cli.utils import split_headers
|
|
27
32
|
|
|
33
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
|
|
36
|
+
def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
|
|
30
37
|
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
|
|
31
38
|
payload = json.load(fd)
|
|
32
39
|
if isinstance(payload, dict):
|
|
33
|
-
|
|
40
|
+
list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
|
|
41
|
+
model
|
|
42
|
+
].model_validate(payload)
|
|
43
|
+
return list_response.resources or []
|
|
34
44
|
return [model.model_validate(item) for item in payload]
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
def describe_server(
|
|
38
|
-
scim_client
|
|
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,
|
|
39
52
|
) -> ScimProvider:
|
|
40
53
|
"""Describe the server with the configuration files, and query it for the others."""
|
|
41
54
|
resource_types = (
|
|
42
55
|
load_objects(resource_types_fd, ResourceType)
|
|
43
56
|
if resource_types_fd
|
|
44
|
-
else
|
|
57
|
+
else cast(
|
|
58
|
+
"ListResponse[ResourceType]", scim_client.query(ResourceType)
|
|
59
|
+
).resources
|
|
60
|
+
or []
|
|
45
61
|
)
|
|
46
62
|
schemas = (
|
|
47
63
|
load_objects(schemas_fd, Schema)
|
|
48
64
|
if schemas_fd
|
|
49
|
-
else scim_client.query(Schema).resources or []
|
|
65
|
+
else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
|
|
50
66
|
)
|
|
51
67
|
config = (
|
|
52
68
|
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
|
|
53
69
|
if service_provider_config_fd
|
|
54
|
-
else scim_client.query(ServiceProviderConfig)
|
|
70
|
+
else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
|
|
55
71
|
)
|
|
56
72
|
return ScimProvider.from_discovery(schemas, resource_types, config)
|
|
57
73
|
|
|
@@ -95,14 +111,14 @@ def describe_server(
|
|
|
95
111
|
)
|
|
96
112
|
@click.pass_context
|
|
97
113
|
def cli(
|
|
98
|
-
ctx,
|
|
99
|
-
url: str,
|
|
114
|
+
ctx: click.Context,
|
|
115
|
+
url: str | None,
|
|
100
116
|
header: list[str],
|
|
101
|
-
no_verify,
|
|
102
|
-
schemas,
|
|
103
|
-
resource_types,
|
|
104
|
-
service_provider_config,
|
|
105
|
-
):
|
|
117
|
+
no_verify: bool,
|
|
118
|
+
schemas: IO[str] | None,
|
|
119
|
+
resource_types: IO[str] | None,
|
|
120
|
+
service_provider_config: IO[str] | None,
|
|
121
|
+
) -> None:
|
|
106
122
|
"""SCIM application development CLI."""
|
|
107
123
|
ctx.ensure_object(dict)
|
|
108
124
|
|
|
@@ -120,17 +136,15 @@ def cli(
|
|
|
120
136
|
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
|
|
121
137
|
raise exception_to_click_error(exc) from exc
|
|
122
138
|
|
|
123
|
-
provider = scim_client.provider
|
|
124
139
|
ctx.obj["client"] = scim_client
|
|
125
|
-
ctx.obj["
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for resource_model in map(provider.model_for, provider.resource_types)
|
|
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
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
if not
|
|
133
|
-
if stdin :=
|
|
146
|
+
if not sys.stdin.isatty(): # pragma: no cover
|
|
147
|
+
if stdin := sys.stdin.read().strip():
|
|
134
148
|
try:
|
|
135
149
|
ctx.obj["stdin"] = json.loads(stdin)
|
|
136
150
|
except json.JSONDecodeError as exc:
|
|
@@ -1,48 +1,73 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
1
4
|
import click
|
|
2
|
-
from click import ClickException
|
|
3
5
|
from pydanclick import from_pydantic
|
|
6
|
+
from scim2_client import Me
|
|
7
|
+
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
4
8
|
from scim2_models import Context
|
|
9
|
+
from scim2_models import Error
|
|
10
|
+
from scim2_models import Resource
|
|
11
|
+
from scim2_models import ResourceType
|
|
5
12
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
6
13
|
|
|
7
14
|
from scim2_cli.utils import DOC_URL
|
|
8
15
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
9
16
|
from scim2_cli.utils import ModelCommand
|
|
17
|
+
from scim2_cli.utils import command_name
|
|
10
18
|
from scim2_cli.utils import escape_options_help
|
|
11
19
|
from scim2_cli.utils import exception_to_click_error
|
|
12
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
|
|
13
25
|
from scim2_cli.utils import unacceptable_fields
|
|
14
26
|
|
|
15
27
|
|
|
16
|
-
def create_payload(
|
|
28
|
+
def create_payload(
|
|
29
|
+
client: SyncSCIMClient,
|
|
30
|
+
resource_type: ResourceType | None,
|
|
31
|
+
payload: Resource[Any] | dict[str, Any],
|
|
32
|
+
indent: bool,
|
|
33
|
+
me: bool,
|
|
34
|
+
) -> None:
|
|
17
35
|
try:
|
|
18
|
-
|
|
36
|
+
# Response payloads are always checked, so the client never returns a dict.
|
|
37
|
+
response = cast(
|
|
38
|
+
"Resource[Any] | Error",
|
|
39
|
+
client.create(
|
|
40
|
+
Me if me else resource_type, payload, raise_scim_errors=False
|
|
41
|
+
),
|
|
42
|
+
)
|
|
19
43
|
|
|
20
44
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
21
45
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
22
46
|
|
|
23
|
-
|
|
24
|
-
click.echo(payload)
|
|
25
|
-
|
|
47
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
26
48
|
|
|
27
|
-
def create_factory(model):
|
|
28
|
-
if not model:
|
|
29
|
-
raise ClickException("Invalid model")
|
|
30
49
|
|
|
50
|
+
def create_factory(
|
|
51
|
+
resource_type: ResourceType, model: type[Resource[Any]]
|
|
52
|
+
) -> click.Command:
|
|
31
53
|
exclude = unacceptable_fields(Context.RESOURCE_CREATION_REQUEST, model)
|
|
32
54
|
|
|
33
55
|
@click.command(
|
|
34
56
|
cls=make_rst_to_ansi_formatter(DOC_URL),
|
|
35
|
-
name=
|
|
36
|
-
)
|
|
37
|
-
@click.option(
|
|
38
|
-
"--indent/--no-indent",
|
|
39
|
-
is_flag=True,
|
|
40
|
-
default=True,
|
|
41
|
-
help="Indent JSON response payloads.",
|
|
57
|
+
name=command_name(resource_type),
|
|
42
58
|
)
|
|
43
|
-
@
|
|
59
|
+
@indent_option("cli_indent")
|
|
60
|
+
@me_option("cli_me")
|
|
61
|
+
@from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
|
|
44
62
|
@click.pass_context
|
|
45
|
-
def create_command(
|
|
63
|
+
def create_command(
|
|
64
|
+
ctx: click.Context,
|
|
65
|
+
cli_indent: bool,
|
|
66
|
+
cli_me: bool,
|
|
67
|
+
obj: Resource[Any] | None,
|
|
68
|
+
*args: Any,
|
|
69
|
+
**kwargs: Any,
|
|
70
|
+
) -> None:
|
|
46
71
|
r"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
47
72
|
|
|
48
73
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -65,6 +90,13 @@ def create_factory(model):
|
|
|
65
90
|
|
|
66
91
|
echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create user
|
|
67
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
|
+
|
|
68
100
|
"""
|
|
69
101
|
if obj == model():
|
|
70
102
|
obj = None
|
|
@@ -74,7 +106,13 @@ def create_factory(model):
|
|
|
74
106
|
click.echo(ctx.get_help())
|
|
75
107
|
ctx.exit(1)
|
|
76
108
|
|
|
77
|
-
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
|
+
)
|
|
78
116
|
|
|
79
117
|
return escape_options_help(create_command)
|
|
80
118
|
|
|
@@ -86,16 +124,12 @@ def create_factory(model):
|
|
|
86
124
|
invoke_without_command=True,
|
|
87
125
|
)
|
|
88
126
|
@click.pass_context
|
|
89
|
-
@
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
default=True,
|
|
93
|
-
help="Indent JSON response payloads.",
|
|
94
|
-
)
|
|
95
|
-
def create_cli(ctx, indent):
|
|
127
|
+
@indent_option("cli_indent")
|
|
128
|
+
@me_option("cli_me")
|
|
129
|
+
def create_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
|
|
96
130
|
"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
97
131
|
|
|
98
|
-
There are subcommands for all the
|
|
132
|
+
There are subcommands for all the resource types of the server, with dynamic attributes.
|
|
99
133
|
See the attributes for :code:`user` with:
|
|
100
134
|
|
|
101
135
|
.. code-block:: bash
|
|
@@ -108,6 +142,9 @@ def create_cli(ctx, indent):
|
|
|
108
142
|
|
|
109
143
|
echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create
|
|
110
144
|
|
|
145
|
+
With :code:`--me`, the request is made on the :code:`/Me` endpoint,
|
|
146
|
+
and the server chooses the resource type.
|
|
147
|
+
|
|
111
148
|
"""
|
|
112
149
|
if ctx.invoked_subcommand is not None:
|
|
113
150
|
return
|
|
@@ -117,4 +154,4 @@ def create_cli(ctx, indent):
|
|
|
117
154
|
click.echo(ctx.get_help())
|
|
118
155
|
ctx.exit(1)
|
|
119
156
|
|
|
120
|
-
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,
|
|
@@ -56,23 +69,26 @@ from .utils import formatted_payload
|
|
|
56
69
|
help="Indent JSON response payloads.",
|
|
57
70
|
)
|
|
58
71
|
def query_cli(
|
|
59
|
-
ctx,
|
|
72
|
+
ctx: click.Context,
|
|
60
73
|
resource_type: str | None,
|
|
61
74
|
id: str | None,
|
|
62
75
|
attribute: list[str],
|
|
63
76
|
excluded_attribute: list[str],
|
|
64
|
-
start_index: int,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
77
|
+
start_index: int | None,
|
|
78
|
+
cursor: str | None,
|
|
79
|
+
count: int | None,
|
|
80
|
+
filter: str | None,
|
|
81
|
+
sort_by: str | None,
|
|
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.
|
|
72
87
|
|
|
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,23 +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
|
|
|
106
|
+
target: ResourceType | type[Resource[Any]] | None = None
|
|
90
107
|
if resource_type:
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
single_resource = bool(id) or resource_type 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
|
|
100
115
|
listing_options = [
|
|
101
116
|
name
|
|
102
117
|
for name, value in (
|
|
103
118
|
("--start-index", start_index),
|
|
119
|
+
("--cursor", cursor),
|
|
104
120
|
("--count", count),
|
|
105
121
|
("--filter", filter),
|
|
106
122
|
("--sort-by", sort_by),
|
|
@@ -119,26 +135,31 @@ def query_cli(
|
|
|
119
135
|
|
|
120
136
|
elif single_resource:
|
|
121
137
|
check_request_payload = True
|
|
122
|
-
payload = ResponseParameters(
|
|
123
|
-
|
|
124
|
-
|
|
138
|
+
payload = ResponseParameters.model_validate(
|
|
139
|
+
{
|
|
140
|
+
"attributes": attribute or None,
|
|
141
|
+
"excluded_attributes": excluded_attribute or None,
|
|
142
|
+
}
|
|
125
143
|
)
|
|
126
144
|
|
|
127
145
|
else:
|
|
128
146
|
check_request_payload = True
|
|
129
|
-
payload = SearchRequest(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
payload = SearchRequest.model_validate(
|
|
148
|
+
{
|
|
149
|
+
"attributes": attribute or None,
|
|
150
|
+
"excluded_attributes": excluded_attribute or None,
|
|
151
|
+
"start_index": start_index,
|
|
152
|
+
"cursor": cursor,
|
|
153
|
+
"count": count,
|
|
154
|
+
"filter": filter,
|
|
155
|
+
"sort_by": sort_by,
|
|
156
|
+
"sort_order": sort_order,
|
|
157
|
+
}
|
|
137
158
|
)
|
|
138
159
|
|
|
139
160
|
try:
|
|
140
161
|
response = ctx.obj["client"].query(
|
|
141
|
-
|
|
162
|
+
Me if me else target,
|
|
142
163
|
id,
|
|
143
164
|
query_parameters=payload,
|
|
144
165
|
check_request_payload=check_request_payload,
|
|
@@ -1,50 +1,75 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
1
4
|
import click
|
|
2
|
-
from click import ClickException
|
|
3
5
|
from pydanclick import from_pydantic
|
|
6
|
+
from scim2_client import Me
|
|
7
|
+
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
4
8
|
from scim2_models import Context
|
|
9
|
+
from scim2_models import Error
|
|
10
|
+
from scim2_models import Resource
|
|
11
|
+
from scim2_models import ResourceType
|
|
5
12
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
6
13
|
|
|
14
|
+
from scim2_cli.utils import command_name
|
|
7
15
|
from scim2_cli.utils import escape_options_help
|
|
8
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
|
|
9
20
|
|
|
10
21
|
from .utils import DOC_URL
|
|
11
22
|
from .utils import SCIM_EXCEPTIONS
|
|
12
23
|
from .utils import ModelCommand
|
|
13
24
|
from .utils import formatted_payload
|
|
25
|
+
from .utils import renamed_fields
|
|
14
26
|
from .utils import unacceptable_fields
|
|
15
27
|
|
|
16
28
|
|
|
17
|
-
def replace_payload(
|
|
29
|
+
def replace_payload(
|
|
30
|
+
client: SyncSCIMClient,
|
|
31
|
+
resource_type: ResourceType | None,
|
|
32
|
+
payload: Resource[Any] | dict[str, Any],
|
|
33
|
+
indent: bool,
|
|
34
|
+
me: bool,
|
|
35
|
+
) -> None:
|
|
18
36
|
try:
|
|
19
|
-
|
|
37
|
+
# Response payloads are always checked, so the client never returns a dict.
|
|
38
|
+
response = cast(
|
|
39
|
+
"Resource[Any] | Error",
|
|
40
|
+
client.replace(
|
|
41
|
+
Me if me else resource_type, payload, raise_scim_errors=False
|
|
42
|
+
),
|
|
43
|
+
)
|
|
20
44
|
|
|
21
45
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
22
46
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
click.echo(payload)
|
|
26
|
-
|
|
48
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
27
49
|
|
|
28
|
-
def replace_factory(model):
|
|
29
|
-
if not model:
|
|
30
|
-
raise ClickException("Invalid model")
|
|
31
50
|
|
|
51
|
+
def replace_factory(
|
|
52
|
+
resource_type: ResourceType, model: type[Resource[Any]]
|
|
53
|
+
) -> click.Command:
|
|
32
54
|
exclude = unacceptable_fields(Context.RESOURCE_REPLACEMENT_REQUEST, model)
|
|
33
55
|
exclude.remove("id")
|
|
34
56
|
|
|
35
57
|
@click.command(
|
|
36
58
|
cls=make_rst_to_ansi_formatter(DOC_URL),
|
|
37
|
-
name=
|
|
59
|
+
name=command_name(resource_type),
|
|
38
60
|
)
|
|
39
|
-
@
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
default=True,
|
|
43
|
-
help="Indent JSON response payloads.",
|
|
44
|
-
)
|
|
45
|
-
@from_pydantic("obj", model, exclude=exclude)
|
|
61
|
+
@indent_option("cli_indent")
|
|
62
|
+
@me_option("cli_me")
|
|
63
|
+
@from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
|
|
46
64
|
@click.pass_context
|
|
47
|
-
def replace_command(
|
|
65
|
+
def replace_command(
|
|
66
|
+
ctx: click.Context,
|
|
67
|
+
cli_indent: bool,
|
|
68
|
+
cli_me: bool,
|
|
69
|
+
obj: Resource[Any] | None,
|
|
70
|
+
*args: Any,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> None:
|
|
48
73
|
r"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
49
74
|
|
|
50
75
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -68,6 +93,13 @@ def replace_factory(model):
|
|
|
68
93
|
|
|
69
94
|
echo '{"id": "xxxx-yyyy", "userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | replace user
|
|
70
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
|
+
|
|
71
103
|
"""
|
|
72
104
|
if obj == model():
|
|
73
105
|
obj = None
|
|
@@ -79,8 +111,10 @@ def replace_factory(model):
|
|
|
79
111
|
|
|
80
112
|
replace_payload(
|
|
81
113
|
ctx.obj["client"],
|
|
114
|
+
resource_type,
|
|
82
115
|
payload,
|
|
83
|
-
|
|
116
|
+
inherited(ctx, "cli_indent", cli_indent),
|
|
117
|
+
inherited(ctx, "cli_me", cli_me),
|
|
84
118
|
)
|
|
85
119
|
|
|
86
120
|
return escape_options_help(replace_command)
|
|
@@ -93,16 +127,12 @@ def replace_factory(model):
|
|
|
93
127
|
invoke_without_command=True,
|
|
94
128
|
)
|
|
95
129
|
@click.pass_context
|
|
96
|
-
@
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
default=True,
|
|
100
|
-
help="Indent JSON response payloads.",
|
|
101
|
-
)
|
|
102
|
-
def replace_cli(ctx, indent):
|
|
130
|
+
@indent_option("cli_indent")
|
|
131
|
+
@me_option("cli_me")
|
|
132
|
+
def replace_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
|
|
103
133
|
"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.1>`_ request on the resources endpoint.
|
|
104
134
|
|
|
105
|
-
There are subcommands for all the
|
|
135
|
+
There are subcommands for all the resource types of the server, with dynamic attributes.
|
|
106
136
|
See the attributes for :code:`user` with:
|
|
107
137
|
|
|
108
138
|
.. code-block:: bash
|
|
@@ -115,6 +145,9 @@ def replace_cli(ctx, indent):
|
|
|
115
145
|
|
|
116
146
|
echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "1234"}' | replace user
|
|
117
147
|
|
|
148
|
+
With :code:`--me`, the request is made on the :code:`/Me` endpoint,
|
|
149
|
+
and the id is not needed.
|
|
150
|
+
|
|
118
151
|
"""
|
|
119
152
|
if ctx.invoked_subcommand is not None:
|
|
120
153
|
return
|
|
@@ -124,8 +157,4 @@ def replace_cli(ctx, indent):
|
|
|
124
157
|
click.echo(ctx.get_help())
|
|
125
158
|
ctx.exit(1)
|
|
126
159
|
|
|
127
|
-
replace_payload(
|
|
128
|
-
ctx.obj["client"],
|
|
129
|
-
payload,
|
|
130
|
-
indent,
|
|
131
|
-
)
|
|
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,
|
|
@@ -49,17 +55,22 @@ from .utils import formatted_payload
|
|
|
49
55
|
help="Indent JSON response payloads.",
|
|
50
56
|
)
|
|
51
57
|
def search_cli(
|
|
52
|
-
ctx,
|
|
58
|
+
ctx: click.Context,
|
|
59
|
+
resource_type: str | None,
|
|
53
60
|
attribute: list[str],
|
|
54
61
|
excluded_attribute: list[str],
|
|
55
|
-
start_index: int,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
start_index: int | None,
|
|
63
|
+
cursor: str | None,
|
|
64
|
+
count: int | None,
|
|
65
|
+
filter: str | None,
|
|
66
|
+
sort_by: str | None,
|
|
67
|
+
sort_order: str | None,
|
|
60
68
|
indent: bool,
|
|
61
|
-
):
|
|
62
|
-
"""Perform a `SCIM
|
|
69
|
+
) -> None:
|
|
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,24 +79,32 @@ 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")
|
|
74
89
|
|
|
75
90
|
else:
|
|
76
91
|
check_request_payload = True
|
|
77
|
-
payload = SearchRequest(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
payload = SearchRequest.model_validate(
|
|
93
|
+
{
|
|
94
|
+
"attributes": attribute or None,
|
|
95
|
+
"excluded_attributes": excluded_attribute or None,
|
|
96
|
+
"start_index": start_index,
|
|
97
|
+
"cursor": cursor,
|
|
98
|
+
"count": count,
|
|
99
|
+
"filter": filter,
|
|
100
|
+
"sort_by": sort_by,
|
|
101
|
+
"sort_order": sort_order,
|
|
102
|
+
}
|
|
85
103
|
)
|
|
86
104
|
|
|
87
105
|
try:
|
|
88
106
|
response = ctx.obj["client"].search(
|
|
107
|
+
target,
|
|
89
108
|
search_request=payload,
|
|
90
109
|
check_request_payload=check_request_payload,
|
|
91
110
|
raise_scim_errors=False,
|
|
@@ -25,7 +25,9 @@ from .utils import escape_control_characters
|
|
|
25
25
|
default=True,
|
|
26
26
|
help="Fail on unexpected content types.",
|
|
27
27
|
)
|
|
28
|
-
def test_cli(
|
|
28
|
+
def test_cli(
|
|
29
|
+
ctx: click.Context, verbose: bool, check_status_code: bool, check_content_type: bool
|
|
30
|
+
) -> None:
|
|
29
31
|
"""Perform a server SCIM compliance check using :doc:`scim2-tester <scim2_tester:index>`.
|
|
30
32
|
|
|
31
33
|
.. code-block:: bash
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from click import ClickException
|
|
12
|
+
from click.core import ParameterSource
|
|
13
|
+
from scim2_client import SCIMClientException
|
|
14
|
+
from scim2_models import BaseModel
|
|
15
|
+
from scim2_models import Context
|
|
16
|
+
from scim2_models import Mutability
|
|
17
|
+
from scim2_models import Resource
|
|
18
|
+
from scim2_models import ResourceType
|
|
19
|
+
from scim2_models import SCIMException
|
|
20
|
+
from sphinx_click.rst_to_ansi_formatter import RstToAnsiGroup
|
|
21
|
+
|
|
22
|
+
DOC_URL = "https://scim2-cli.readthedocs.io/"
|
|
23
|
+
INDENTATION_SIZE = 4
|
|
24
|
+
|
|
25
|
+
SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)
|
|
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
|
+
|
|
55
|
+
CONTROL_CHARACTERS = re.compile(
|
|
56
|
+
"[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HeaderType(click.types.StringParamType):
|
|
61
|
+
envvar_list_splitter = ";"
|
|
62
|
+
name = "HEADER"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Color(StrEnum):
|
|
66
|
+
black = "black"
|
|
67
|
+
red = "red"
|
|
68
|
+
green = "green"
|
|
69
|
+
yellow = "yellow"
|
|
70
|
+
blue = "blue"
|
|
71
|
+
magenta = "magenta"
|
|
72
|
+
cyan = "cyan"
|
|
73
|
+
white = "white"
|
|
74
|
+
bright_black = "bright_black"
|
|
75
|
+
bright_red = "bright_red"
|
|
76
|
+
bright_green = "bright_green"
|
|
77
|
+
bright_yellow = "bright_yellow"
|
|
78
|
+
bright_blue = "bright_blue"
|
|
79
|
+
bright_magenta = "bright_magenta"
|
|
80
|
+
bright_cyan = "bright_cyan"
|
|
81
|
+
bright_white = "bright_white"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def escape_control_characters(text: object) -> str:
|
|
85
|
+
"""Replace the control characters of a text by their escaped form.
|
|
86
|
+
|
|
87
|
+
The texts coming from the server would otherwise be interpreted by the
|
|
88
|
+
terminal as escape sequences.
|
|
89
|
+
"""
|
|
90
|
+
return CONTROL_CHARACTERS.sub(
|
|
91
|
+
lambda match: match.group().encode("unicode_escape").decode(), str(text)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def escape_options_help(command: click.Command) -> click.Command:
|
|
96
|
+
"""Escape the option help texts pydanclick takes from the schema descriptions."""
|
|
97
|
+
for param in command.params:
|
|
98
|
+
if isinstance(param, click.Option) and param.help:
|
|
99
|
+
param.help = escape_control_characters(param.help)
|
|
100
|
+
return command
|
|
101
|
+
|
|
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
|
+
|
|
128
|
+
def formatted_payload(obj: Any, indent: bool) -> str:
|
|
129
|
+
return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def split_headers(headers: list[str]) -> dict[str, str]:
|
|
133
|
+
"""Make a dict from header strings.
|
|
134
|
+
|
|
135
|
+
['Authorization: Bearer token'] → '{"Authorization": "Bearer token"}'
|
|
136
|
+
"""
|
|
137
|
+
return {
|
|
138
|
+
header[: header.index(":")].strip(): header[header.index(":") + 1 :].strip()
|
|
139
|
+
for header in headers
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ModelCommand(RstToAnsiGroup):
|
|
144
|
+
"""CLI commands that take a subcommand for each resource type."""
|
|
145
|
+
|
|
146
|
+
base_url = DOC_URL
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
*args: Any,
|
|
151
|
+
factory: Callable[[ResourceType, type[Resource[Any]]], click.Command],
|
|
152
|
+
**kwargs: Any,
|
|
153
|
+
) -> None:
|
|
154
|
+
super().__init__(*args, **kwargs)
|
|
155
|
+
self.factory = factory
|
|
156
|
+
|
|
157
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
158
|
+
ctx.ensure_object(dict)
|
|
159
|
+
base = super().list_commands(ctx)
|
|
160
|
+
lazy = sorted(ctx.obj.get("resource_types", {}).keys())
|
|
161
|
+
return base + lazy
|
|
162
|
+
|
|
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)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def is_field_acceptable(
|
|
172
|
+
context: Context, model: type[BaseModel], field_name: str
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""Indicate whether a field is acceptable as part of a SCIM payload for a given context."""
|
|
175
|
+
mutability = model.get_field_annotation(field_name, Mutability)
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
context
|
|
179
|
+
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
180
|
+
and mutability == Mutability.read_only
|
|
181
|
+
):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
if (
|
|
185
|
+
context in (Context.RESOURCE_QUERY_REQUEST, Context.SEARCH_REQUEST)
|
|
186
|
+
and mutability == Mutability.write_only
|
|
187
|
+
):
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
if (
|
|
191
|
+
context == Context.RESOURCE_REPLACEMENT_REQUEST
|
|
192
|
+
and mutability == Mutability.immutable
|
|
193
|
+
):
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def unacceptable_fields(context: Context, model: type[BaseModel]) -> list[str]:
|
|
200
|
+
excluded = [
|
|
201
|
+
field_name
|
|
202
|
+
for field_name in model.model_fields
|
|
203
|
+
if not is_field_acceptable(context, model, field_name)
|
|
204
|
+
]
|
|
205
|
+
excluded.append("schemas")
|
|
206
|
+
return excluded
|
|
207
|
+
|
|
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
|
+
|
|
219
|
+
def exception_to_click_error(exception: Exception) -> click.ClickException:
|
|
220
|
+
message = str(exception)
|
|
221
|
+
if hasattr(exception, "__notes__"):
|
|
222
|
+
message += "\n" + "\n".join(exception.__notes__)
|
|
223
|
+
return click.ClickException(escape_control_characters(message))
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import re
|
|
3
|
-
from enum import StrEnum
|
|
4
|
-
|
|
5
|
-
import click
|
|
6
|
-
from scim2_client import SCIMClientException
|
|
7
|
-
from scim2_models import SCIMException
|
|
8
|
-
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
9
|
-
|
|
10
|
-
DOC_URL = "https://scim2-cli.readthedocs.io/"
|
|
11
|
-
INDENTATION_SIZE = 4
|
|
12
|
-
|
|
13
|
-
SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)
|
|
14
|
-
|
|
15
|
-
CONTROL_CHARACTERS = re.compile(
|
|
16
|
-
"[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class HeaderType(click.ParamType):
|
|
21
|
-
envvar_list_splitter = ";"
|
|
22
|
-
name = "HEADER"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class Color(StrEnum):
|
|
26
|
-
black = "black"
|
|
27
|
-
red = "red"
|
|
28
|
-
green = "green"
|
|
29
|
-
yellow = "yellow"
|
|
30
|
-
blue = "blue"
|
|
31
|
-
magenta = "magenta"
|
|
32
|
-
cyan = "cyan"
|
|
33
|
-
white = "white"
|
|
34
|
-
bright_black = "bright_black"
|
|
35
|
-
bright_red = "bright_red"
|
|
36
|
-
bright_green = "bright_green"
|
|
37
|
-
bright_yellow = "bright_yellow"
|
|
38
|
-
bright_blue = "bright_blue"
|
|
39
|
-
bright_magenta = "bright_magenta"
|
|
40
|
-
bright_cyan = "bright_cyan"
|
|
41
|
-
bright_white = "bright_white"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def escape_control_characters(text) -> str:
|
|
45
|
-
"""Replace the control characters of a text by their escaped form.
|
|
46
|
-
|
|
47
|
-
The texts coming from the server would otherwise be interpreted by the
|
|
48
|
-
terminal as escape sequences.
|
|
49
|
-
"""
|
|
50
|
-
return CONTROL_CHARACTERS.sub(
|
|
51
|
-
lambda match: match.group().encode("unicode_escape").decode(), str(text)
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def escape_options_help(command):
|
|
56
|
-
"""Escape the option help texts pydanclick takes from the schema descriptions."""
|
|
57
|
-
for param in command.params:
|
|
58
|
-
if param.help:
|
|
59
|
-
param.help = escape_control_characters(param.help)
|
|
60
|
-
return command
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def formatted_payload(obj, indent):
|
|
64
|
-
indent = INDENTATION_SIZE if indent else False
|
|
65
|
-
return json.dumps(obj, indent=indent)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def split_headers(headers: list[str]) -> dict[str, str]:
|
|
69
|
-
"""Make a dict from header strings.
|
|
70
|
-
|
|
71
|
-
['Authorization: Bearer token'] → '{"Authorization": "Bearer token"}'
|
|
72
|
-
"""
|
|
73
|
-
return {
|
|
74
|
-
header[: header.index(":")].strip(): header[header.index(":") + 1 :].strip()
|
|
75
|
-
for header in headers
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
RSTCommand: click.Group = make_rst_to_ansi_formatter(DOC_URL, group=True)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class ModelCommand(RSTCommand):
|
|
83
|
-
"""CLI commands that takes a model subcommand."""
|
|
84
|
-
|
|
85
|
-
def __init__(self, *args, factory, **kwargs):
|
|
86
|
-
super().__init__(*args, **kwargs)
|
|
87
|
-
self.factory = factory
|
|
88
|
-
|
|
89
|
-
def list_commands(self, ctx):
|
|
90
|
-
ctx.ensure_object(dict)
|
|
91
|
-
base = super().list_commands(ctx)
|
|
92
|
-
lazy = sorted(ctx.obj.get("resource_models", {}).keys())
|
|
93
|
-
return base + lazy
|
|
94
|
-
|
|
95
|
-
def get_command(self, ctx, cmd_name):
|
|
96
|
-
model = ctx.obj["resource_models"].get(cmd_name)
|
|
97
|
-
return self.factory(model)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def is_field_acceptable(context, model, field_name) -> bool:
|
|
101
|
-
"""Indicate whether a field is acceptable as part of a SCIM payload for a given context."""
|
|
102
|
-
from scim2_models import Context
|
|
103
|
-
from scim2_models import Mutability
|
|
104
|
-
|
|
105
|
-
mutability = model.get_field_annotation(field_name, Mutability)
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
context
|
|
109
|
-
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
110
|
-
and mutability == Mutability.read_only
|
|
111
|
-
):
|
|
112
|
-
return False
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
context in (Context.RESOURCE_QUERY_REQUEST, Context.SEARCH_REQUEST)
|
|
116
|
-
and mutability == Mutability.write_only
|
|
117
|
-
):
|
|
118
|
-
return False
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
context == Context.RESOURCE_REPLACEMENT_REQUEST
|
|
122
|
-
and mutability == Mutability.immutable
|
|
123
|
-
):
|
|
124
|
-
return False
|
|
125
|
-
|
|
126
|
-
return True
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def unacceptable_fields(context, model):
|
|
130
|
-
excluded = [
|
|
131
|
-
field_name
|
|
132
|
-
for field_name in model.model_fields
|
|
133
|
-
if not is_field_acceptable(context, model, field_name)
|
|
134
|
-
]
|
|
135
|
-
excluded.append("schemas")
|
|
136
|
-
return excluded
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def exception_to_click_error(exception):
|
|
140
|
-
message = str(exception)
|
|
141
|
-
if hasattr(exception, "__notes__"):
|
|
142
|
-
message += "\n" + "\n".join(exception.__notes__)
|
|
143
|
-
return click.ClickException(escape_control_characters(message))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|