scim2-cli 0.3.0__tar.gz → 0.4.1__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.3.0 → scim2_cli-0.4.1}/PKG-INFO +5 -6
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/pyproject.toml +27 -10
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/pyproject.toml.orig +20 -10
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/__init__.py +70 -71
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/create.py +25 -8
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/delete.py +1 -1
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/query.py +28 -23
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/replace.py +25 -8
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/search.py +17 -15
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/test.py +7 -4
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/scim2_cli/utils.py +54 -25
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/LICENSE.md +0 -0
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/README.md +0 -0
- {scim2_cli-0.3.0 → scim2_cli-0.4.1}/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.4.1
|
|
4
4
|
Summary: SCIM application development CLI
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop
|
|
@@ -208,7 +208,6 @@ License: Apache License
|
|
|
208
208
|
limitations under the License.
|
|
209
209
|
Classifier: Intended Audience :: Developers
|
|
210
210
|
Classifier: Development Status :: 3 - Alpha
|
|
211
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
212
211
|
Classifier: Programming Language :: Python :: 3.11
|
|
213
212
|
Classifier: Programming Language :: Python :: 3.12
|
|
214
213
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -219,12 +218,12 @@ Classifier: Environment :: Web Environment
|
|
|
219
218
|
Classifier: Programming Language :: Python
|
|
220
219
|
Classifier: Operating System :: OS Independent
|
|
221
220
|
Requires-Dist: click>=8.1.7
|
|
222
|
-
Requires-Dist: scim2-client[httpx2]>=0.
|
|
223
|
-
Requires-Dist: scim2-tester>=0.
|
|
224
|
-
Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.
|
|
221
|
+
Requires-Dist: scim2-client[httpx2]>=0.11.2
|
|
222
|
+
Requires-Dist: scim2-tester>=0.4.0
|
|
223
|
+
Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.2.0
|
|
225
224
|
Requires-Dist: pydanclick>=0.5.0
|
|
226
225
|
Requires-Dist: pygments>=2.18.0
|
|
227
|
-
Requires-Python: >=3.
|
|
226
|
+
Requires-Python: >=3.11
|
|
228
227
|
Project-URL: documentation, https://scim2-cli.readthedocs.io
|
|
229
228
|
Project-URL: repository, https://github.com/python-scim/scim2-cli
|
|
230
229
|
Project-URL: changelog, https://scim2-cli.readthedocs.io/en/latest/changelog.html
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "SCIM application development CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -17,7 +17,6 @@ keywords = [
|
|
|
17
17
|
classifiers = [
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
"Development Status :: 3 - Alpha",
|
|
20
|
-
"Programming Language :: Python :: 3.10",
|
|
21
20
|
"Programming Language :: Python :: 3.11",
|
|
22
21
|
"Programming Language :: Python :: 3.12",
|
|
23
22
|
"Programming Language :: Python :: 3.13",
|
|
@@ -28,12 +27,12 @@ classifiers = [
|
|
|
28
27
|
"Programming Language :: Python",
|
|
29
28
|
"Operating System :: OS Independent",
|
|
30
29
|
]
|
|
31
|
-
requires-python = ">= 3.
|
|
30
|
+
requires-python = ">= 3.11"
|
|
32
31
|
dependencies = [
|
|
33
32
|
"click>=8.1.7",
|
|
34
|
-
"scim2-client[httpx2]>=0.
|
|
35
|
-
"scim2-tester>=0.
|
|
36
|
-
"sphinx-click-rst-to-ansi-formatter>=0.
|
|
33
|
+
"scim2-client[httpx2]>=0.11.2",
|
|
34
|
+
"scim2-tester>=0.4.0",
|
|
35
|
+
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
37
36
|
"pydanclick>=0.5.0",
|
|
38
37
|
"pygments>=2.18.0",
|
|
39
38
|
]
|
|
@@ -57,10 +56,10 @@ scim2 = "scim2_cli:cli"
|
|
|
57
56
|
|
|
58
57
|
[dependency-groups]
|
|
59
58
|
dev = [
|
|
60
|
-
"mypy>=
|
|
59
|
+
"mypy>=2.3.1",
|
|
61
60
|
"prek>=0.1.0",
|
|
62
61
|
"pytest>=8.2.1",
|
|
63
|
-
"pytest-
|
|
62
|
+
"pytest-cov>=6.0.0",
|
|
64
63
|
"pytest-httpserver>=1.0.10",
|
|
65
64
|
"tox-uv>=1.16.0",
|
|
66
65
|
]
|
|
@@ -76,6 +75,18 @@ doc = [
|
|
|
76
75
|
]
|
|
77
76
|
bundle = ["pyinstaller>=6.10.0"]
|
|
78
77
|
|
|
78
|
+
[tool.uv]
|
|
79
|
+
exclude-newer = "14 days"
|
|
80
|
+
|
|
81
|
+
[tool.uv.exclude-newer-package]
|
|
82
|
+
scim2-models = false
|
|
83
|
+
scim2-client = false
|
|
84
|
+
scim2-server = false
|
|
85
|
+
scim2-cli = false
|
|
86
|
+
scim2-tester = false
|
|
87
|
+
pytest-scim2-server = false
|
|
88
|
+
sphinx-click-rst-to-ansi-formatter = false
|
|
89
|
+
|
|
79
90
|
[tool.uv.build-backend]
|
|
80
91
|
module-root = ""
|
|
81
92
|
|
|
@@ -94,6 +105,7 @@ exclude_lines = [
|
|
|
94
105
|
"raise NotImplementedError",
|
|
95
106
|
"except ImportError",
|
|
96
107
|
"if app.debug",
|
|
108
|
+
"if TYPE_CHECKING:",
|
|
97
109
|
]
|
|
98
110
|
|
|
99
111
|
[tool.ruff.lint]
|
|
@@ -131,6 +143,12 @@ ignore-words-list = "datas"
|
|
|
131
143
|
|
|
132
144
|
[tool.mypy]
|
|
133
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
|
|
134
152
|
|
|
135
153
|
[tool.pytest.ini_options]
|
|
136
154
|
testpaths = ["tests"]
|
|
@@ -139,7 +157,6 @@ testpaths = ["tests"]
|
|
|
139
157
|
requires = ["tox>=4.19"]
|
|
140
158
|
env_list = [
|
|
141
159
|
"style",
|
|
142
|
-
"py310",
|
|
143
160
|
"py311",
|
|
144
161
|
"py312",
|
|
145
162
|
"py313",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "SCIM application development CLI"
|
|
9
9
|
authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
|
|
10
10
|
license = {file = "LICENSE.md"}
|
|
@@ -13,7 +13,6 @@ keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644"]
|
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Intended Audience :: Developers",
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
|
-
"Programming Language :: Python :: 3.10",
|
|
17
16
|
"Programming Language :: Python :: 3.11",
|
|
18
17
|
"Programming Language :: Python :: 3.12",
|
|
19
18
|
"Programming Language :: Python :: 3.13",
|
|
@@ -25,12 +24,12 @@ classifiers = [
|
|
|
25
24
|
"Operating System :: OS Independent",
|
|
26
25
|
]
|
|
27
26
|
|
|
28
|
-
requires-python = ">= 3.
|
|
27
|
+
requires-python = ">= 3.11"
|
|
29
28
|
dependencies = [
|
|
30
29
|
"click>=8.1.7",
|
|
31
|
-
"scim2-client[httpx2]>=0.
|
|
32
|
-
"scim2-tester>=0.
|
|
33
|
-
"sphinx-click-rst-to-ansi-formatter>=0.
|
|
30
|
+
"scim2-client[httpx2]>=0.11.2",
|
|
31
|
+
"scim2-tester>=0.4.0",
|
|
32
|
+
"sphinx-click-rst-to-ansi-formatter>=0.2.0",
|
|
34
33
|
"pydanclick>=0.5.0",
|
|
35
34
|
"pygments>=2.18.0",
|
|
36
35
|
]
|
|
@@ -43,10 +42,10 @@ funding = "https://github.com/sponsors/python-scim"
|
|
|
43
42
|
|
|
44
43
|
[dependency-groups]
|
|
45
44
|
dev = [
|
|
46
|
-
"mypy>=
|
|
45
|
+
"mypy>=2.3.1",
|
|
47
46
|
"prek>=0.1.0",
|
|
48
47
|
"pytest>=8.2.1",
|
|
49
|
-
"pytest-
|
|
48
|
+
"pytest-cov>=6.0.0",
|
|
50
49
|
"pytest-httpserver>=1.0.10",
|
|
51
50
|
"tox-uv>=1.16.0",
|
|
52
51
|
]
|
|
@@ -68,6 +67,11 @@ bundle = [
|
|
|
68
67
|
scim = "scim2_cli:cli"
|
|
69
68
|
scim2 = "scim2_cli:cli"
|
|
70
69
|
|
|
70
|
+
[tool.uv]
|
|
71
|
+
exclude-newer = "14 days"
|
|
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 }
|
|
74
|
+
|
|
71
75
|
[tool.uv.build-backend]
|
|
72
76
|
module-root = ""
|
|
73
77
|
|
|
@@ -86,6 +90,7 @@ exclude_lines = [
|
|
|
86
90
|
"raise NotImplementedError",
|
|
87
91
|
"except ImportError",
|
|
88
92
|
"if app.debug",
|
|
93
|
+
"if TYPE_CHECKING:",
|
|
89
94
|
]
|
|
90
95
|
|
|
91
96
|
[tool.ruff.lint]
|
|
@@ -125,6 +130,12 @@ ignore-words-list = "datas"
|
|
|
125
130
|
plugins = [
|
|
126
131
|
"pydantic.mypy"
|
|
127
132
|
]
|
|
133
|
+
files = ["scim2_cli"]
|
|
134
|
+
strict = true
|
|
135
|
+
|
|
136
|
+
[tool.pydantic-mypy]
|
|
137
|
+
init_forbid_extra = true
|
|
138
|
+
init_typed = true
|
|
128
139
|
|
|
129
140
|
[tool.pytest.ini_options]
|
|
130
141
|
testpaths = ["tests"]
|
|
@@ -133,7 +144,6 @@ testpaths = ["tests"]
|
|
|
133
144
|
requires = ["tox>=4.19"]
|
|
134
145
|
env_list = [
|
|
135
146
|
"style",
|
|
136
|
-
"py310",
|
|
137
147
|
"py311",
|
|
138
148
|
"py312",
|
|
139
149
|
"py313",
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import re
|
|
3
|
+
import sys
|
|
4
|
+
from typing import IO
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
from typing import cast
|
|
3
8
|
|
|
4
9
|
import click
|
|
5
10
|
from httpx2 import Client
|
|
6
11
|
from scim2_client.engines.httpx2 import SyncSCIMClient
|
|
7
|
-
from scim2_models import Group
|
|
8
12
|
from scim2_models import ListResponse
|
|
9
13
|
from scim2_models import Resource
|
|
10
14
|
from scim2_models import ResourceType
|
|
11
15
|
from scim2_models import Schema
|
|
16
|
+
from scim2_models import ScimProvider
|
|
17
|
+
from scim2_models import ScimProviderError
|
|
12
18
|
from scim2_models import ServiceProviderConfig
|
|
13
|
-
from scim2_models import User
|
|
14
19
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
15
20
|
|
|
16
21
|
from scim2_cli.create import create_cli
|
|
@@ -22,48 +27,50 @@ from scim2_cli.test import test_cli
|
|
|
22
27
|
from scim2_cli.utils import DOC_URL
|
|
23
28
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
24
29
|
from scim2_cli.utils import HeaderType
|
|
30
|
+
from scim2_cli.utils import escape_control_characters
|
|
25
31
|
from scim2_cli.utils import exception_to_click_error
|
|
26
32
|
from scim2_cli.utils import split_headers
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
) ->
|
|
32
|
-
list
|
|
33
|
-
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
34
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
|
|
38
|
+
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
|
|
39
|
+
payload = json.load(fd)
|
|
40
|
+
if isinstance(payload, dict):
|
|
41
|
+
list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
|
|
42
|
+
model
|
|
43
|
+
].model_validate(payload)
|
|
44
|
+
return list_response.resources or []
|
|
45
|
+
return [model.model_validate(item) for item in payload]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def describe_server(
|
|
49
|
+
scim_client: SyncSCIMClient,
|
|
50
|
+
schemas_fd: IO[str] | None,
|
|
51
|
+
resource_types_fd: IO[str] | None,
|
|
52
|
+
service_provider_config_fd: IO[str] | None,
|
|
53
|
+
) -> ScimProvider:
|
|
54
|
+
"""Describe the server with the configuration files, and query it for the others."""
|
|
55
|
+
resource_types = (
|
|
56
|
+
load_objects(resource_types_fd, ResourceType)
|
|
57
|
+
if resource_types_fd
|
|
58
|
+
else cast(
|
|
59
|
+
"ListResponse[ResourceType]", scim_client.query(ResourceType)
|
|
60
|
+
).resources
|
|
61
|
+
or []
|
|
62
|
+
)
|
|
63
|
+
schemas = (
|
|
64
|
+
load_objects(schemas_fd, Schema)
|
|
65
|
+
if schemas_fd
|
|
66
|
+
else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
|
|
67
|
+
)
|
|
68
|
+
config = (
|
|
69
|
+
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
|
|
70
|
+
if service_provider_config_fd
|
|
71
|
+
else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
|
|
72
|
+
)
|
|
73
|
+
return ScimProvider.from_discovery(schemas, resource_types, config)
|
|
67
74
|
|
|
68
75
|
|
|
69
76
|
@click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
|
|
@@ -73,7 +80,7 @@ def load_config_files(
|
|
|
73
80
|
"--header",
|
|
74
81
|
multiple=True,
|
|
75
82
|
type=HeaderType(),
|
|
76
|
-
help="Headers to pass in the HTTP requests. Can be passed multiple times.",
|
|
83
|
+
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.",
|
|
77
84
|
envvar="SCIM_CLI_HEADERS",
|
|
78
85
|
)
|
|
79
86
|
@click.option(
|
|
@@ -105,14 +112,14 @@ def load_config_files(
|
|
|
105
112
|
)
|
|
106
113
|
@click.pass_context
|
|
107
114
|
def cli(
|
|
108
|
-
ctx,
|
|
109
|
-
url: str,
|
|
115
|
+
ctx: click.Context,
|
|
116
|
+
url: str | None,
|
|
110
117
|
header: list[str],
|
|
111
|
-
no_verify,
|
|
112
|
-
schemas,
|
|
113
|
-
resource_types,
|
|
114
|
-
service_provider_config,
|
|
115
|
-
):
|
|
118
|
+
no_verify: bool,
|
|
119
|
+
schemas: IO[str] | None,
|
|
120
|
+
resource_types: IO[str] | None,
|
|
121
|
+
service_provider_config: IO[str] | None,
|
|
122
|
+
) -> None:
|
|
116
123
|
"""SCIM application development CLI."""
|
|
117
124
|
ctx.ensure_object(dict)
|
|
118
125
|
|
|
@@ -122,33 +129,25 @@ def cli(
|
|
|
122
129
|
headers_dict = split_headers(header)
|
|
123
130
|
client = Client(base_url=url, headers=headers_dict, verify=not no_verify)
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
schemas, resource_types, service_provider_config
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
scim_client = SyncSCIMClient(
|
|
130
|
-
client,
|
|
131
|
-
resource_models=resource_models,
|
|
132
|
-
resource_types=resource_types_obj,
|
|
133
|
-
service_provider_config=spc_obj,
|
|
134
|
-
)
|
|
132
|
+
scim_client = SyncSCIMClient(client)
|
|
135
133
|
try:
|
|
136
|
-
scim_client.
|
|
137
|
-
|
|
138
|
-
resource_types=not bool(resource_types),
|
|
139
|
-
service_provider_config=not bool(service_provider_config),
|
|
134
|
+
scim_client.provider = describe_server(
|
|
135
|
+
scim_client, schemas, resource_types, service_provider_config
|
|
140
136
|
)
|
|
141
|
-
except SCIM_EXCEPTIONS as exc:
|
|
137
|
+
except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
|
|
142
138
|
raise exception_to_click_error(exc) from exc
|
|
143
139
|
|
|
140
|
+
provider = scim_client.provider
|
|
144
141
|
ctx.obj["client"] = scim_client
|
|
145
|
-
ctx.obj["resource_models"] = {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
142
|
+
ctx.obj["resource_models"] = {}
|
|
143
|
+
for resource_type in provider.resource_types:
|
|
144
|
+
resource_model = provider.model_for(resource_type)
|
|
145
|
+
assert resource_model is not None
|
|
146
|
+
name = re.sub(r"\[.*\]", "", resource_model.__name__.lower())
|
|
147
|
+
ctx.obj["resource_models"][escape_control_characters(name)] = resource_model
|
|
148
|
+
|
|
149
|
+
if not sys.stdin.isatty(): # pragma: no cover
|
|
150
|
+
if stdin := sys.stdin.read().strip():
|
|
152
151
|
try:
|
|
153
152
|
ctx.obj["stdin"] = json.loads(stdin)
|
|
154
153
|
except json.JSONDecodeError as exc:
|
|
@@ -1,29 +1,40 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
1
4
|
import click
|
|
2
5
|
from click import ClickException
|
|
3
6
|
from pydanclick import from_pydantic
|
|
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
|
|
5
11
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
6
12
|
|
|
7
13
|
from scim2_cli.utils import DOC_URL
|
|
8
14
|
from scim2_cli.utils import SCIM_EXCEPTIONS
|
|
9
15
|
from scim2_cli.utils import ModelCommand
|
|
16
|
+
from scim2_cli.utils import escape_options_help
|
|
10
17
|
from scim2_cli.utils import exception_to_click_error
|
|
11
18
|
from scim2_cli.utils import formatted_payload
|
|
12
19
|
from scim2_cli.utils import unacceptable_fields
|
|
13
20
|
|
|
14
21
|
|
|
15
|
-
def create_payload(
|
|
22
|
+
def create_payload(
|
|
23
|
+
client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
|
|
24
|
+
) -> None:
|
|
16
25
|
try:
|
|
17
|
-
|
|
26
|
+
# Response payloads are always checked, so the client never returns a dict.
|
|
27
|
+
response = cast(
|
|
28
|
+
"Resource[Any] | Error", client.create(payload, raise_scim_errors=False)
|
|
29
|
+
)
|
|
18
30
|
|
|
19
31
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
20
32
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
click.echo(payload)
|
|
34
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
24
35
|
|
|
25
36
|
|
|
26
|
-
def create_factory(model):
|
|
37
|
+
def create_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
27
38
|
if not model:
|
|
28
39
|
raise ClickException("Invalid model")
|
|
29
40
|
|
|
@@ -41,7 +52,13 @@ def create_factory(model):
|
|
|
41
52
|
)
|
|
42
53
|
@from_pydantic("obj", model, exclude=exclude)
|
|
43
54
|
@click.pass_context
|
|
44
|
-
def create_command(
|
|
55
|
+
def create_command(
|
|
56
|
+
ctx: click.Context,
|
|
57
|
+
indent: bool,
|
|
58
|
+
obj: Resource[Any] | None,
|
|
59
|
+
*args: Any,
|
|
60
|
+
**kwargs: Any,
|
|
61
|
+
) -> None:
|
|
45
62
|
r"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
46
63
|
|
|
47
64
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -75,7 +92,7 @@ def create_factory(model):
|
|
|
75
92
|
|
|
76
93
|
create_payload(ctx.obj["client"], payload, indent)
|
|
77
94
|
|
|
78
|
-
return create_command
|
|
95
|
+
return escape_options_help(create_command)
|
|
79
96
|
|
|
80
97
|
|
|
81
98
|
@click.command(
|
|
@@ -91,7 +108,7 @@ def create_factory(model):
|
|
|
91
108
|
default=True,
|
|
92
109
|
help="Indent JSON response payloads.",
|
|
93
110
|
)
|
|
94
|
-
def create_cli(ctx, indent):
|
|
111
|
+
def create_cli(ctx: click.Context, indent: bool) -> None:
|
|
95
112
|
"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
96
113
|
|
|
97
114
|
There are subcommands for all the available models, with dynamic attributes.
|
|
@@ -21,7 +21,7 @@ from .utils import formatted_payload
|
|
|
21
21
|
help="Indent JSON response payloads.",
|
|
22
22
|
)
|
|
23
23
|
@click.pass_context
|
|
24
|
-
def delete_cli(ctx, resource_type, id, indent):
|
|
24
|
+
def delete_cli(ctx: click.Context, resource_type: str, id: str, indent: bool) -> None:
|
|
25
25
|
"""Perform a `SCIM DELETE query <https://www.rfc-editor.org/rfc/rfc7644#section-3.6>`_ request.
|
|
26
26
|
|
|
27
27
|
.. code-block:: bash
|
|
@@ -56,18 +56,18 @@ from .utils import formatted_payload
|
|
|
56
56
|
help="Indent JSON response payloads.",
|
|
57
57
|
)
|
|
58
58
|
def query_cli(
|
|
59
|
-
ctx,
|
|
59
|
+
ctx: click.Context,
|
|
60
60
|
resource_type: str | None,
|
|
61
61
|
id: str | None,
|
|
62
62
|
attribute: list[str],
|
|
63
63
|
excluded_attribute: list[str],
|
|
64
|
-
start_index: int,
|
|
65
|
-
count: int,
|
|
66
|
-
filter: str,
|
|
67
|
-
sort_by: str,
|
|
68
|
-
sort_order: str,
|
|
64
|
+
start_index: int | None,
|
|
65
|
+
count: int | None,
|
|
66
|
+
filter: str | None,
|
|
67
|
+
sort_by: str | None,
|
|
68
|
+
sort_order: str | None,
|
|
69
69
|
indent: bool,
|
|
70
|
-
):
|
|
70
|
+
) -> None:
|
|
71
71
|
"""Perform a `SCIM GET <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_ request on the :code:`RESOURCE_TYPE` endpoint.
|
|
72
72
|
|
|
73
73
|
- If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is `1234`, then the request will made on the :code:`/Users/1234` endpoint.
|
|
@@ -84,19 +84,20 @@ def query_cli(
|
|
|
84
84
|
echo '{"startIndex": 50, "count": 10}' | query user
|
|
85
85
|
|
|
86
86
|
"""
|
|
87
|
-
for
|
|
88
|
-
ctx.obj["resource_models"][
|
|
87
|
+
for discovery_model in (Schema, ResourceType, ServiceProviderConfig):
|
|
88
|
+
ctx.obj["resource_models"][discovery_model.__name__.lower()] = discovery_model
|
|
89
89
|
|
|
90
|
+
model = None
|
|
90
91
|
if resource_type:
|
|
91
92
|
try:
|
|
92
|
-
|
|
93
|
+
model = ctx.obj["resource_models"][resource_type]
|
|
93
94
|
except KeyError as exc:
|
|
94
95
|
ok_values = ", ".join(ctx.obj["resource_models"])
|
|
95
96
|
raise ClickException(
|
|
96
97
|
f"Unknown resource type '{resource_type}. Available values are: {ok_values}'"
|
|
97
98
|
) from exc
|
|
98
99
|
|
|
99
|
-
single_resource = bool(id) or
|
|
100
|
+
single_resource = bool(id) or model is ServiceProviderConfig
|
|
100
101
|
listing_options = [
|
|
101
102
|
name
|
|
102
103
|
for name, value in (
|
|
@@ -119,26 +120,30 @@ def query_cli(
|
|
|
119
120
|
|
|
120
121
|
elif single_resource:
|
|
121
122
|
check_request_payload = True
|
|
122
|
-
payload = ResponseParameters(
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
payload = ResponseParameters.model_validate(
|
|
124
|
+
{
|
|
125
|
+
"attributes": attribute,
|
|
126
|
+
"excluded_attributes": excluded_attribute,
|
|
127
|
+
}
|
|
125
128
|
)
|
|
126
129
|
|
|
127
130
|
else:
|
|
128
131
|
check_request_payload = True
|
|
129
|
-
payload = SearchRequest(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
payload = SearchRequest.model_validate(
|
|
133
|
+
{
|
|
134
|
+
"attributes": attribute,
|
|
135
|
+
"excluded_attributes": excluded_attribute,
|
|
136
|
+
"start_index": start_index,
|
|
137
|
+
"count": count,
|
|
138
|
+
"filter": filter,
|
|
139
|
+
"sort_by": sort_by,
|
|
140
|
+
"sort_order": sort_order,
|
|
141
|
+
}
|
|
137
142
|
)
|
|
138
143
|
|
|
139
144
|
try:
|
|
140
145
|
response = ctx.obj["client"].query(
|
|
141
|
-
|
|
146
|
+
model,
|
|
142
147
|
id,
|
|
143
148
|
query_parameters=payload,
|
|
144
149
|
check_request_payload=check_request_payload,
|
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import cast
|
|
3
|
+
|
|
1
4
|
import click
|
|
2
5
|
from click import ClickException
|
|
3
6
|
from pydanclick import from_pydantic
|
|
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
|
|
5
11
|
from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
6
12
|
|
|
13
|
+
from scim2_cli.utils import escape_options_help
|
|
7
14
|
from scim2_cli.utils import exception_to_click_error
|
|
8
15
|
|
|
9
16
|
from .utils import DOC_URL
|
|
@@ -13,18 +20,22 @@ from .utils import formatted_payload
|
|
|
13
20
|
from .utils import unacceptable_fields
|
|
14
21
|
|
|
15
22
|
|
|
16
|
-
def replace_payload(
|
|
23
|
+
def replace_payload(
|
|
24
|
+
client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
|
|
25
|
+
) -> None:
|
|
17
26
|
try:
|
|
18
|
-
|
|
27
|
+
# Response payloads are always checked, so the client never returns a dict.
|
|
28
|
+
response = cast(
|
|
29
|
+
"Resource[Any] | Error", client.replace(payload, raise_scim_errors=False)
|
|
30
|
+
)
|
|
19
31
|
|
|
20
32
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
21
33
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
click.echo(payload)
|
|
35
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
25
36
|
|
|
26
37
|
|
|
27
|
-
def replace_factory(model):
|
|
38
|
+
def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
28
39
|
if not model:
|
|
29
40
|
raise ClickException("Invalid model")
|
|
30
41
|
|
|
@@ -43,7 +54,13 @@ def replace_factory(model):
|
|
|
43
54
|
)
|
|
44
55
|
@from_pydantic("obj", model, exclude=exclude)
|
|
45
56
|
@click.pass_context
|
|
46
|
-
def replace_command(
|
|
57
|
+
def replace_command(
|
|
58
|
+
ctx: click.Context,
|
|
59
|
+
indent: bool,
|
|
60
|
+
obj: Resource[Any] | None,
|
|
61
|
+
*args: Any,
|
|
62
|
+
**kwargs: Any,
|
|
63
|
+
) -> None:
|
|
47
64
|
r"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
48
65
|
|
|
49
66
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -82,7 +99,7 @@ def replace_factory(model):
|
|
|
82
99
|
indent,
|
|
83
100
|
)
|
|
84
101
|
|
|
85
|
-
return replace_command
|
|
102
|
+
return escape_options_help(replace_command)
|
|
86
103
|
|
|
87
104
|
|
|
88
105
|
@click.command(
|
|
@@ -98,7 +115,7 @@ def replace_factory(model):
|
|
|
98
115
|
default=True,
|
|
99
116
|
help="Indent JSON response payloads.",
|
|
100
117
|
)
|
|
101
|
-
def replace_cli(ctx, indent):
|
|
118
|
+
def replace_cli(ctx: click.Context, indent: bool) -> None:
|
|
102
119
|
"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.1>`_ request on the resources endpoint.
|
|
103
120
|
|
|
104
121
|
There are subcommands for all the available models, with dynamic attributes.
|
|
@@ -49,16 +49,16 @@ from .utils import formatted_payload
|
|
|
49
49
|
help="Indent JSON response payloads.",
|
|
50
50
|
)
|
|
51
51
|
def search_cli(
|
|
52
|
-
ctx,
|
|
52
|
+
ctx: click.Context,
|
|
53
53
|
attribute: list[str],
|
|
54
54
|
excluded_attribute: list[str],
|
|
55
|
-
start_index: int,
|
|
56
|
-
count: int,
|
|
57
|
-
filter: str,
|
|
58
|
-
sort_by: str,
|
|
59
|
-
sort_order: str,
|
|
55
|
+
start_index: int | None,
|
|
56
|
+
count: int | None,
|
|
57
|
+
filter: str | None,
|
|
58
|
+
sort_by: str | None,
|
|
59
|
+
sort_order: str | None,
|
|
60
60
|
indent: bool,
|
|
61
|
-
):
|
|
61
|
+
) -> None:
|
|
62
62
|
"""Perform a `SCIM GET <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_ request on the :code:`/.search` endpoint.
|
|
63
63
|
|
|
64
64
|
Data passed in JSON format to stdin is sent as request arguments and all the other query arguments are ignored:
|
|
@@ -74,14 +74,16 @@ def search_cli(
|
|
|
74
74
|
|
|
75
75
|
else:
|
|
76
76
|
check_request_payload = True
|
|
77
|
-
payload = SearchRequest(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
payload = SearchRequest.model_validate(
|
|
78
|
+
{
|
|
79
|
+
"attributes": attribute,
|
|
80
|
+
"excluded_attributes": excluded_attribute,
|
|
81
|
+
"start_index": start_index,
|
|
82
|
+
"count": count,
|
|
83
|
+
"filter": filter,
|
|
84
|
+
"sort_by": sort_by,
|
|
85
|
+
"sort_order": sort_order,
|
|
86
|
+
}
|
|
85
87
|
)
|
|
86
88
|
|
|
87
89
|
try:
|
|
@@ -7,6 +7,7 @@ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
|
|
|
7
7
|
|
|
8
8
|
from .utils import DOC_URL
|
|
9
9
|
from .utils import Color
|
|
10
|
+
from .utils import escape_control_characters
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="test")
|
|
@@ -24,7 +25,9 @@ from .utils import Color
|
|
|
24
25
|
default=True,
|
|
25
26
|
help="Fail on unexpected content types.",
|
|
26
27
|
)
|
|
27
|
-
def test_cli(
|
|
28
|
+
def test_cli(
|
|
29
|
+
ctx: click.Context, verbose: bool, check_status_code: bool, check_content_type: bool
|
|
30
|
+
) -> None:
|
|
28
31
|
"""Perform a server SCIM compliance check using :doc:`scim2-tester <scim2_tester:index>`.
|
|
29
32
|
|
|
30
33
|
.. code-block:: bash
|
|
@@ -43,11 +46,11 @@ def test_cli(ctx, verbose, check_status_code, check_content_type):
|
|
|
43
46
|
else:
|
|
44
47
|
status = click.style(result.status.name, fg=Color.red)
|
|
45
48
|
success = False
|
|
46
|
-
click.echo(f"{status} {result.title}")
|
|
49
|
+
click.echo(f"{status} {escape_control_characters(result.title)}")
|
|
47
50
|
|
|
48
51
|
if result.reason:
|
|
49
|
-
click.echo(f" {result.reason}")
|
|
52
|
+
click.echo(f" {escape_control_characters(result.reason)}")
|
|
50
53
|
if verbose and result.data:
|
|
51
|
-
click.echo(f" {result.data}")
|
|
54
|
+
click.echo(f" {escape_control_characters(result.data)}")
|
|
52
55
|
|
|
53
56
|
sys.exit(0 if success else 1)
|
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import
|
|
3
|
-
from
|
|
2
|
+
import re
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
4
6
|
|
|
5
7
|
import click
|
|
6
8
|
from scim2_client import SCIMClientException
|
|
9
|
+
from scim2_models import BaseModel
|
|
10
|
+
from scim2_models import Context
|
|
11
|
+
from scim2_models import Mutability
|
|
12
|
+
from scim2_models import Resource
|
|
7
13
|
from scim2_models import SCIMException
|
|
8
|
-
from sphinx_click.rst_to_ansi_formatter import
|
|
14
|
+
from sphinx_click.rst_to_ansi_formatter import RstToAnsiGroup
|
|
9
15
|
|
|
10
16
|
DOC_URL = "https://scim2-cli.readthedocs.io/"
|
|
11
17
|
INDENTATION_SIZE = 4
|
|
12
18
|
|
|
13
19
|
SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)
|
|
14
20
|
|
|
21
|
+
CONTROL_CHARACTERS = re.compile(
|
|
22
|
+
"[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"
|
|
23
|
+
)
|
|
15
24
|
|
|
16
|
-
|
|
25
|
+
|
|
26
|
+
class HeaderType(click.types.StringParamType):
|
|
17
27
|
envvar_list_splitter = ";"
|
|
18
28
|
name = "HEADER"
|
|
19
29
|
|
|
20
30
|
|
|
21
|
-
class Color(
|
|
31
|
+
class Color(StrEnum):
|
|
22
32
|
black = "black"
|
|
23
33
|
red = "red"
|
|
24
34
|
green = "green"
|
|
@@ -37,9 +47,27 @@ class Color(str, Enum):
|
|
|
37
47
|
bright_white = "bright_white"
|
|
38
48
|
|
|
39
49
|
|
|
40
|
-
def
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
def escape_control_characters(text: object) -> str:
|
|
51
|
+
"""Replace the control characters of a text by their escaped form.
|
|
52
|
+
|
|
53
|
+
The texts coming from the server would otherwise be interpreted by the
|
|
54
|
+
terminal as escape sequences.
|
|
55
|
+
"""
|
|
56
|
+
return CONTROL_CHARACTERS.sub(
|
|
57
|
+
lambda match: match.group().encode("unicode_escape").decode(), str(text)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def escape_options_help(command: click.Command) -> click.Command:
|
|
62
|
+
"""Escape the option help texts pydanclick takes from the schema descriptions."""
|
|
63
|
+
for param in command.params:
|
|
64
|
+
if isinstance(param, click.Option) and param.help:
|
|
65
|
+
param.help = escape_control_characters(param.help)
|
|
66
|
+
return command
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def formatted_payload(obj: Any, indent: bool) -> str:
|
|
70
|
+
return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
|
|
43
71
|
|
|
44
72
|
|
|
45
73
|
def split_headers(headers: list[str]) -> dict[str, str]:
|
|
@@ -53,32 +81,35 @@ def split_headers(headers: list[str]) -> dict[str, str]:
|
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class ModelCommand(RSTCommand):
|
|
84
|
+
class ModelCommand(RstToAnsiGroup):
|
|
60
85
|
"""CLI commands that takes a model subcommand."""
|
|
61
86
|
|
|
62
|
-
|
|
87
|
+
base_url = DOC_URL
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
*args: Any,
|
|
92
|
+
factory: Callable[[type[Resource[Any]] | None], click.Command],
|
|
93
|
+
**kwargs: Any,
|
|
94
|
+
) -> None:
|
|
63
95
|
super().__init__(*args, **kwargs)
|
|
64
96
|
self.factory = factory
|
|
65
97
|
|
|
66
|
-
def list_commands(self, ctx):
|
|
98
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
67
99
|
ctx.ensure_object(dict)
|
|
68
100
|
base = super().list_commands(ctx)
|
|
69
101
|
lazy = sorted(ctx.obj.get("resource_models", {}).keys())
|
|
70
102
|
return base + lazy
|
|
71
103
|
|
|
72
|
-
def get_command(self, ctx, cmd_name):
|
|
104
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
|
|
73
105
|
model = ctx.obj["resource_models"].get(cmd_name)
|
|
74
106
|
return self.factory(model)
|
|
75
107
|
|
|
76
108
|
|
|
77
|
-
def is_field_acceptable(
|
|
109
|
+
def is_field_acceptable(
|
|
110
|
+
context: Context, model: type[BaseModel], field_name: str
|
|
111
|
+
) -> bool:
|
|
78
112
|
"""Indicate whether a field is acceptable as part of a SCIM payload for a given context."""
|
|
79
|
-
from scim2_models import Context
|
|
80
|
-
from scim2_models import Mutability
|
|
81
|
-
|
|
82
113
|
mutability = model.get_field_annotation(field_name, Mutability)
|
|
83
114
|
|
|
84
115
|
if (
|
|
@@ -103,7 +134,7 @@ def is_field_acceptable(context, model, field_name) -> bool:
|
|
|
103
134
|
return True
|
|
104
135
|
|
|
105
136
|
|
|
106
|
-
def unacceptable_fields(context, model):
|
|
137
|
+
def unacceptable_fields(context: Context, model: type[BaseModel]) -> list[str]:
|
|
107
138
|
excluded = [
|
|
108
139
|
field_name
|
|
109
140
|
for field_name in model.model_fields
|
|
@@ -113,10 +144,8 @@ def unacceptable_fields(context, model):
|
|
|
113
144
|
return excluded
|
|
114
145
|
|
|
115
146
|
|
|
116
|
-
def exception_to_click_error(exception):
|
|
147
|
+
def exception_to_click_error(exception: Exception) -> click.ClickException:
|
|
117
148
|
message = str(exception)
|
|
118
|
-
if
|
|
119
|
-
exception, "__notes__"
|
|
120
|
-
): # pragma: no cover
|
|
149
|
+
if hasattr(exception, "__notes__"):
|
|
121
150
|
message += "\n" + "\n".join(exception.__notes__)
|
|
122
|
-
return click.ClickException(message)
|
|
151
|
+
return click.ClickException(escape_control_characters(message))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|