scim2-cli 0.4.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.4.0 → scim2_cli-0.4.1}/PKG-INFO +3 -3
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/pyproject.toml +12 -4
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/pyproject.toml.orig +13 -5
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/__init__.py +39 -22
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/create.py +23 -7
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/delete.py +1 -1
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/query.py +28 -23
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/replace.py +23 -7
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/search.py +17 -15
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/test.py +3 -1
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/scim2_cli/utils.py +29 -21
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/LICENSE.md +0 -0
- {scim2_cli-0.4.0 → scim2_cli-0.4.1}/README.md +0 -0
- {scim2_cli-0.4.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.4.
|
|
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
|
|
@@ -218,9 +218,9 @@ Classifier: Environment :: Web Environment
|
|
|
218
218
|
Classifier: Programming Language :: Python
|
|
219
219
|
Classifier: Operating System :: OS Independent
|
|
220
220
|
Requires-Dist: click>=8.1.7
|
|
221
|
-
Requires-Dist: scim2-client[httpx2]>=0.
|
|
221
|
+
Requires-Dist: scim2-client[httpx2]>=0.11.2
|
|
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.4.
|
|
7
|
+
version = "0.4.1"
|
|
8
8
|
description = "SCIM application development CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -30,9 +30,9 @@ classifiers = [
|
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
32
|
"click>=8.1.7",
|
|
33
|
-
"scim2-client[httpx2]>=0.
|
|
33
|
+
"scim2-client[httpx2]>=0.11.2",
|
|
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.4.
|
|
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"}
|
|
@@ -27,9 +27,9 @@ classifiers = [
|
|
|
27
27
|
requires-python = ">= 3.11"
|
|
28
28
|
dependencies = [
|
|
29
29
|
"click>=8.1.7",
|
|
30
|
-
"scim2-client[httpx2]>=0.
|
|
30
|
+
"scim2-client[httpx2]>=0.11.2",
|
|
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,16 @@
|
|
|
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
12
|
from scim2_models import ListResponse
|
|
13
|
+
from scim2_models import Resource
|
|
8
14
|
from scim2_models import ResourceType
|
|
9
15
|
from scim2_models import Schema
|
|
10
16
|
from scim2_models import ScimProvider
|
|
@@ -25,33 +31,44 @@ 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
|
|
|
34
|
+
ResourceT = TypeVar("ResourceT", bound=Resource[Any])
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
|
|
37
|
+
def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
|
|
30
38
|
"""Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
|
|
31
39
|
payload = json.load(fd)
|
|
32
40
|
if isinstance(payload, dict):
|
|
33
|
-
|
|
41
|
+
list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
|
|
42
|
+
model
|
|
43
|
+
].model_validate(payload)
|
|
44
|
+
return list_response.resources or []
|
|
34
45
|
return [model.model_validate(item) for item in payload]
|
|
35
46
|
|
|
36
47
|
|
|
37
48
|
def describe_server(
|
|
38
|
-
scim_client
|
|
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,
|
|
39
53
|
) -> ScimProvider:
|
|
40
54
|
"""Describe the server with the configuration files, and query it for the others."""
|
|
41
55
|
resource_types = (
|
|
42
56
|
load_objects(resource_types_fd, ResourceType)
|
|
43
57
|
if resource_types_fd
|
|
44
|
-
else
|
|
58
|
+
else cast(
|
|
59
|
+
"ListResponse[ResourceType]", scim_client.query(ResourceType)
|
|
60
|
+
).resources
|
|
61
|
+
or []
|
|
45
62
|
)
|
|
46
63
|
schemas = (
|
|
47
64
|
load_objects(schemas_fd, Schema)
|
|
48
65
|
if schemas_fd
|
|
49
|
-
else scim_client.query(Schema).resources or []
|
|
66
|
+
else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
|
|
50
67
|
)
|
|
51
68
|
config = (
|
|
52
69
|
ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
|
|
53
70
|
if service_provider_config_fd
|
|
54
|
-
else scim_client.query(ServiceProviderConfig)
|
|
71
|
+
else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
|
|
55
72
|
)
|
|
56
73
|
return ScimProvider.from_discovery(schemas, resource_types, config)
|
|
57
74
|
|
|
@@ -95,14 +112,14 @@ def describe_server(
|
|
|
95
112
|
)
|
|
96
113
|
@click.pass_context
|
|
97
114
|
def cli(
|
|
98
|
-
ctx,
|
|
99
|
-
url: str,
|
|
115
|
+
ctx: click.Context,
|
|
116
|
+
url: str | None,
|
|
100
117
|
header: list[str],
|
|
101
|
-
no_verify,
|
|
102
|
-
schemas,
|
|
103
|
-
resource_types,
|
|
104
|
-
service_provider_config,
|
|
105
|
-
):
|
|
118
|
+
no_verify: bool,
|
|
119
|
+
schemas: IO[str] | None,
|
|
120
|
+
resource_types: IO[str] | None,
|
|
121
|
+
service_provider_config: IO[str] | None,
|
|
122
|
+
) -> None:
|
|
106
123
|
"""SCIM application development CLI."""
|
|
107
124
|
ctx.ensure_object(dict)
|
|
108
125
|
|
|
@@ -122,15 +139,15 @@ def cli(
|
|
|
122
139
|
|
|
123
140
|
provider = scim_client.provider
|
|
124
141
|
ctx.obj["client"] = scim_client
|
|
125
|
-
ctx.obj["resource_models"] = {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if not
|
|
133
|
-
if stdin :=
|
|
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():
|
|
134
151
|
try:
|
|
135
152
|
ctx.obj["stdin"] = json.loads(stdin)
|
|
136
153
|
except json.JSONDecodeError as exc:
|
|
@@ -1,7 +1,13 @@
|
|
|
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
|
|
@@ -13,18 +19,22 @@ from scim2_cli.utils import formatted_payload
|
|
|
13
19
|
from scim2_cli.utils import unacceptable_fields
|
|
14
20
|
|
|
15
21
|
|
|
16
|
-
def create_payload(
|
|
22
|
+
def create_payload(
|
|
23
|
+
client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
|
|
24
|
+
) -> None:
|
|
17
25
|
try:
|
|
18
|
-
|
|
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
|
+
)
|
|
19
30
|
|
|
20
31
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
21
32
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
click.echo(payload)
|
|
34
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
25
35
|
|
|
26
36
|
|
|
27
|
-
def create_factory(model):
|
|
37
|
+
def create_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
28
38
|
if not model:
|
|
29
39
|
raise ClickException("Invalid model")
|
|
30
40
|
|
|
@@ -42,7 +52,13 @@ def create_factory(model):
|
|
|
42
52
|
)
|
|
43
53
|
@from_pydantic("obj", model, exclude=exclude)
|
|
44
54
|
@click.pass_context
|
|
45
|
-
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:
|
|
46
62
|
r"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
47
63
|
|
|
48
64
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -92,7 +108,7 @@ def create_factory(model):
|
|
|
92
108
|
default=True,
|
|
93
109
|
help="Indent JSON response payloads.",
|
|
94
110
|
)
|
|
95
|
-
def create_cli(ctx, indent):
|
|
111
|
+
def create_cli(ctx: click.Context, indent: bool) -> None:
|
|
96
112
|
"""Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
97
113
|
|
|
98
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,7 +1,13 @@
|
|
|
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 escape_options_help
|
|
@@ -14,18 +20,22 @@ from .utils import formatted_payload
|
|
|
14
20
|
from .utils import unacceptable_fields
|
|
15
21
|
|
|
16
22
|
|
|
17
|
-
def replace_payload(
|
|
23
|
+
def replace_payload(
|
|
24
|
+
client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
|
|
25
|
+
) -> None:
|
|
18
26
|
try:
|
|
19
|
-
|
|
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
|
+
)
|
|
20
31
|
|
|
21
32
|
except SCIM_EXCEPTIONS as scim_exc:
|
|
22
33
|
raise exception_to_click_error(scim_exc) from scim_exc
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
click.echo(payload)
|
|
35
|
+
click.echo(formatted_payload(response.model_dump(), indent))
|
|
26
36
|
|
|
27
37
|
|
|
28
|
-
def replace_factory(model):
|
|
38
|
+
def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
|
|
29
39
|
if not model:
|
|
30
40
|
raise ClickException("Invalid model")
|
|
31
41
|
|
|
@@ -44,7 +54,13 @@ def replace_factory(model):
|
|
|
44
54
|
)
|
|
45
55
|
@from_pydantic("obj", model, exclude=exclude)
|
|
46
56
|
@click.pass_context
|
|
47
|
-
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:
|
|
48
64
|
r"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
|
|
49
65
|
|
|
50
66
|
Input data can be passed through parameters like :code:`--external-id`.
|
|
@@ -99,7 +115,7 @@ def replace_factory(model):
|
|
|
99
115
|
default=True,
|
|
100
116
|
help="Indent JSON response payloads.",
|
|
101
117
|
)
|
|
102
|
-
def replace_cli(ctx, indent):
|
|
118
|
+
def replace_cli(ctx: click.Context, indent: bool) -> None:
|
|
103
119
|
"""Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.1>`_ request on the resources endpoint.
|
|
104
120
|
|
|
105
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:
|
|
@@ -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
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import re
|
|
3
|
+
from collections.abc import Callable
|
|
3
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
|
|
@@ -17,7 +23,7 @@ CONTROL_CHARACTERS = re.compile(
|
|
|
17
23
|
)
|
|
18
24
|
|
|
19
25
|
|
|
20
|
-
class HeaderType(click.
|
|
26
|
+
class HeaderType(click.types.StringParamType):
|
|
21
27
|
envvar_list_splitter = ";"
|
|
22
28
|
name = "HEADER"
|
|
23
29
|
|
|
@@ -41,7 +47,7 @@ class Color(StrEnum):
|
|
|
41
47
|
bright_white = "bright_white"
|
|
42
48
|
|
|
43
49
|
|
|
44
|
-
def escape_control_characters(text) -> str:
|
|
50
|
+
def escape_control_characters(text: object) -> str:
|
|
45
51
|
"""Replace the control characters of a text by their escaped form.
|
|
46
52
|
|
|
47
53
|
The texts coming from the server would otherwise be interpreted by the
|
|
@@ -52,17 +58,16 @@ def escape_control_characters(text) -> str:
|
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
|
|
55
|
-
def escape_options_help(command):
|
|
61
|
+
def escape_options_help(command: click.Command) -> click.Command:
|
|
56
62
|
"""Escape the option help texts pydanclick takes from the schema descriptions."""
|
|
57
63
|
for param in command.params:
|
|
58
|
-
if param.help:
|
|
64
|
+
if isinstance(param, click.Option) and param.help:
|
|
59
65
|
param.help = escape_control_characters(param.help)
|
|
60
66
|
return command
|
|
61
67
|
|
|
62
68
|
|
|
63
|
-
def formatted_payload(obj, indent):
|
|
64
|
-
indent
|
|
65
|
-
return json.dumps(obj, indent=indent)
|
|
69
|
+
def formatted_payload(obj: Any, indent: bool) -> str:
|
|
70
|
+
return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
|
|
66
71
|
|
|
67
72
|
|
|
68
73
|
def split_headers(headers: list[str]) -> dict[str, str]:
|
|
@@ -76,32 +81,35 @@ def split_headers(headers: list[str]) -> dict[str, str]:
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class ModelCommand(RSTCommand):
|
|
84
|
+
class ModelCommand(RstToAnsiGroup):
|
|
83
85
|
"""CLI commands that takes a model subcommand."""
|
|
84
86
|
|
|
85
|
-
|
|
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:
|
|
86
95
|
super().__init__(*args, **kwargs)
|
|
87
96
|
self.factory = factory
|
|
88
97
|
|
|
89
|
-
def list_commands(self, ctx):
|
|
98
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
90
99
|
ctx.ensure_object(dict)
|
|
91
100
|
base = super().list_commands(ctx)
|
|
92
101
|
lazy = sorted(ctx.obj.get("resource_models", {}).keys())
|
|
93
102
|
return base + lazy
|
|
94
103
|
|
|
95
|
-
def get_command(self, ctx, cmd_name):
|
|
104
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
|
|
96
105
|
model = ctx.obj["resource_models"].get(cmd_name)
|
|
97
106
|
return self.factory(model)
|
|
98
107
|
|
|
99
108
|
|
|
100
|
-
def is_field_acceptable(
|
|
109
|
+
def is_field_acceptable(
|
|
110
|
+
context: Context, model: type[BaseModel], field_name: str
|
|
111
|
+
) -> bool:
|
|
101
112
|
"""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
113
|
mutability = model.get_field_annotation(field_name, Mutability)
|
|
106
114
|
|
|
107
115
|
if (
|
|
@@ -126,7 +134,7 @@ def is_field_acceptable(context, model, field_name) -> bool:
|
|
|
126
134
|
return True
|
|
127
135
|
|
|
128
136
|
|
|
129
|
-
def unacceptable_fields(context, model):
|
|
137
|
+
def unacceptable_fields(context: Context, model: type[BaseModel]) -> list[str]:
|
|
130
138
|
excluded = [
|
|
131
139
|
field_name
|
|
132
140
|
for field_name in model.model_fields
|
|
@@ -136,7 +144,7 @@ def unacceptable_fields(context, model):
|
|
|
136
144
|
return excluded
|
|
137
145
|
|
|
138
146
|
|
|
139
|
-
def exception_to_click_error(exception):
|
|
147
|
+
def exception_to_click_error(exception: Exception) -> click.ClickException:
|
|
140
148
|
message = str(exception)
|
|
141
149
|
if hasattr(exception, "__notes__"):
|
|
142
150
|
message += "\n" + "\n".join(exception.__notes__)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|