scim2-cli 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: SCIM application development CLI
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop
@@ -218,8 +218,8 @@ Classifier: Environment :: Web Environment
218
218
  Classifier: Programming Language :: Python
219
219
  Classifier: Operating System :: OS Independent
220
220
  Requires-Dist: click>=8.5.0
221
- Requires-Dist: scim2-client[httpx2]>=0.13.0
222
- Requires-Dist: scim2-tester>=0.4.0
221
+ Requires-Dist: scim2-client[httpx2]>=0.13.2
222
+ Requires-Dist: scim2-tester>=0.5.1
223
223
  Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.2.0
224
224
  Requires-Dist: pydanclick>=0.5.0
225
225
  Requires-Dist: pygments>=2.18.0
@@ -243,8 +243,9 @@ It allows users and groups creations, modifications and deletions to be synchron
243
243
 
244
244
  ## Features
245
245
 
246
- - **CRUD Commands**: `create`, `query`, `replace` and `delete` resources from the command line
246
+ - **CRUD Commands**: `create`, `query`, `replace`, `modify` and `delete` resources from the command line
247
247
  - **Search Command**: Query resources using SCIM filters, sorting and pagination
248
+ - **Bulk Command**: Send bulk requests passed as JSON through stdin
248
249
  - **Compliance Testing**: Built-in `test` command using [scim2-tester](https://scim2-tester.readthedocs.io) to validate server RFC compliance
249
250
  - **Server Discovery**: Automatic retrieval of server schemas and resource types
250
251
  - **Dynamic CLI Options**: Command options generated from server schemas
@@ -11,8 +11,9 @@ It allows users and groups creations, modifications and deletions to be synchron
11
11
 
12
12
  ## Features
13
13
 
14
- - **CRUD Commands**: `create`, `query`, `replace` and `delete` resources from the command line
14
+ - **CRUD Commands**: `create`, `query`, `replace`, `modify` and `delete` resources from the command line
15
15
  - **Search Command**: Query resources using SCIM filters, sorting and pagination
16
+ - **Bulk Command**: Send bulk requests passed as JSON through stdin
16
17
  - **Compliance Testing**: Built-in `test` command using [scim2-tester](https://scim2-tester.readthedocs.io) to validate server RFC compliance
17
18
  - **Server Discovery**: Automatic retrieval of server schemas and resource types
18
19
  - **Dynamic CLI Options**: Command options generated from server schemas
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "SCIM application development CLI"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -30,8 +30,8 @@ classifiers = [
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
32
  "click>=8.5.0",
33
- "scim2-client[httpx2]>=0.13.0",
34
- "scim2-tester>=0.4.0",
33
+ "scim2-client[httpx2]>=0.13.2",
34
+ "scim2-tester>=0.5.1",
35
35
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
36
36
  "pydanclick>=0.5.0",
37
37
  "pygments>=2.18.0",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "SCIM application development CLI"
9
9
  authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
10
10
  license = {file = "LICENSE.md"}
@@ -27,8 +27,8 @@ classifiers = [
27
27
  requires-python = ">= 3.11"
28
28
  dependencies = [
29
29
  "click>=8.5.0",
30
- "scim2-client[httpx2]>=0.13.0",
31
- "scim2-tester>=0.4.0",
30
+ "scim2-client[httpx2]>=0.13.2",
31
+ "scim2-tester>=0.5.1",
32
32
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
33
33
  "pydanclick>=0.5.0",
34
34
  "pygments>=2.18.0",
@@ -0,0 +1,88 @@
1
+ from typing import IO
2
+
3
+ import click
4
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
5
+
6
+ from scim2_cli.bulk import bulk_cli
7
+ from scim2_cli.create import create_cli
8
+ from scim2_cli.delete import delete_cli
9
+ from scim2_cli.modify import modify_cli
10
+ from scim2_cli.query import query_cli
11
+ from scim2_cli.replace import replace_cli
12
+ from scim2_cli.search import search_cli
13
+ from scim2_cli.session import Session
14
+ from scim2_cli.test import test_cli
15
+ from scim2_cli.utils import DOC_URL
16
+ from scim2_cli.utils import HeaderType
17
+ from scim2_cli.utils import split_headers
18
+
19
+
20
+ @click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
21
+ @click.option("-u", "--url", help="The SCIM server endpoint.", envvar="SCIM_CLI_URL")
22
+ @click.option(
23
+ "-h",
24
+ "--header",
25
+ multiple=True,
26
+ type=HeaderType(),
27
+ help="Headers to pass in the HTTP requests. Can be passed multiple times. Other users of the machine can see the command line arguments, so pass the secrets with the SCIM_CLI_HEADERS environment variable.",
28
+ envvar="SCIM_CLI_HEADERS",
29
+ )
30
+ @click.option(
31
+ "--no-verify",
32
+ is_flag=True,
33
+ default=False,
34
+ help="Don't perform https certificate verifications.",
35
+ )
36
+ @click.option(
37
+ "-s",
38
+ "--schemas",
39
+ type=click.File(),
40
+ help="Path to a JSON file containing a list of SCIM Schemas. Those schemas will be assumed to be available on the server. If unset, they will be downloaded.",
41
+ envvar="SCIM_CLI_SCHEMAS",
42
+ )
43
+ @click.option(
44
+ "-r",
45
+ "--resource-types",
46
+ type=click.File(),
47
+ help="Path to a JSON file containing a list of SCIM ResourceType. Those resource types will be assumed to be available on the server. If unset, they will be downloaded.",
48
+ envvar="SCIM_CLI_RESOURCE_TYPES",
49
+ )
50
+ @click.option(
51
+ "-c",
52
+ "--service-provider-config",
53
+ type=click.File(),
54
+ help="Path to a JSON file containing the ServiceProviderConfig content of the server. Will be downloaded otherwise.",
55
+ envvar="SCIM_CLI_SERVICE_PROVIDER_CONFIG",
56
+ )
57
+ @click.pass_context
58
+ def cli(
59
+ ctx: click.Context,
60
+ url: str | None,
61
+ header: list[str],
62
+ no_verify: bool,
63
+ schemas: IO[str] | None,
64
+ resource_types: IO[str] | None,
65
+ service_provider_config: IO[str] | None,
66
+ ) -> None:
67
+ """SCIM application development CLI."""
68
+ ctx.obj = Session(
69
+ url=url,
70
+ headers=split_headers(header),
71
+ verify=not no_verify,
72
+ schemas=schemas,
73
+ resource_types=resource_types,
74
+ service_provider_config=service_provider_config,
75
+ )
76
+
77
+
78
+ cli.add_command(create_cli)
79
+ cli.add_command(query_cli)
80
+ cli.add_command(replace_cli)
81
+ cli.add_command(modify_cli)
82
+ cli.add_command(delete_cli)
83
+ cli.add_command(search_cli)
84
+ cli.add_command(bulk_cli)
85
+ cli.add_command(test_cli)
86
+
87
+ if __name__ == "__main__": # pragma: no cover
88
+ cli()
@@ -0,0 +1,36 @@
1
+ import click
2
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
3
+
4
+ from scim2_cli.utils import DOC_URL
5
+ from scim2_cli.utils import SCIM_EXCEPTIONS
6
+ from scim2_cli.utils import echo_response
7
+ from scim2_cli.utils import exception_to_click_error
8
+ from scim2_cli.utils import indent_option
9
+
10
+
11
+ @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="bulk")
12
+ @indent_option()
13
+ @click.pass_context
14
+ def bulk_cli(ctx: click.Context, indent: bool) -> None:
15
+ """Perform a `SCIM bulk <https://www.rfc-editor.org/rfc/rfc7644#section-3.7>`_ request.
16
+
17
+ The bulk request is passed through stdin in JSON format:
18
+
19
+ .. code-block:: bash
20
+
21
+ echo '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:BulkRequest"], "Operations": [{"method": "POST", "path": "/Users", "bulkId": "qwerty", "data": {"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "userName": "bjensen@example.com"}}]}' | bulk
22
+
23
+ The request is checked against the bulk capabilities of the server before being sent.
24
+ """
25
+ payload = ctx.obj.stdin
26
+ if not payload:
27
+ click.echo(ctx.get_help())
28
+ ctx.exit(1)
29
+
30
+ try:
31
+ response = ctx.obj.client.bulk(payload, raise_scim_errors=False)
32
+
33
+ except SCIM_EXCEPTIONS as scim_exc:
34
+ raise exception_to_click_error(scim_exc) from scim_exc
35
+
36
+ echo_response(response, indent)
@@ -15,9 +15,9 @@ from scim2_cli.utils import DOC_URL
15
15
  from scim2_cli.utils import SCIM_EXCEPTIONS
16
16
  from scim2_cli.utils import ModelCommand
17
17
  from scim2_cli.utils import command_name
18
+ from scim2_cli.utils import echo_response
18
19
  from scim2_cli.utils import escape_options_help
19
20
  from scim2_cli.utils import exception_to_click_error
20
- from scim2_cli.utils import formatted_payload
21
21
  from scim2_cli.utils import indent_option
22
22
  from scim2_cli.utils import inherited
23
23
  from scim2_cli.utils import me_option
@@ -44,7 +44,7 @@ def create_payload(
44
44
  except SCIM_EXCEPTIONS as scim_exc:
45
45
  raise exception_to_click_error(scim_exc) from scim_exc
46
46
 
47
- click.echo(formatted_payload(response.model_dump(), indent))
47
+ echo_response(response, indent)
48
48
 
49
49
 
50
50
  def create_factory(
@@ -80,8 +80,8 @@ def create_factory(
80
80
 
81
81
  .. code-block:: bash
82
82
 
83
- scim create user \\
84
- --user-name "foo" \\
83
+ scim create user \
84
+ --user-name "foo" \
85
85
  --emails '[{"value":"foo@bar.example", "primary": true}, {"value": "foo@baz.example"}]'
86
86
 
87
87
  Input can also be passed through stdin in JSON format:
@@ -101,13 +101,13 @@ def create_factory(
101
101
  if obj == model():
102
102
  obj = None
103
103
 
104
- payload = ctx.obj.get("stdin") or obj
104
+ payload = ctx.obj.stdin or obj
105
105
  if not payload:
106
106
  click.echo(ctx.get_help())
107
107
  ctx.exit(1)
108
108
 
109
109
  create_payload(
110
- ctx.obj["client"],
110
+ ctx.obj.client,
111
111
  resource_type,
112
112
  payload,
113
113
  inherited(ctx, "cli_indent", cli_indent),
@@ -149,9 +149,9 @@ def create_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
149
149
  if ctx.invoked_subcommand is not None:
150
150
  return
151
151
 
152
- payload = ctx.obj.get("stdin")
152
+ payload = ctx.obj.stdin
153
153
  if not payload:
154
154
  click.echo(ctx.get_help())
155
155
  ctx.exit(1)
156
156
 
157
- create_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
157
+ create_payload(ctx.obj.client, None, payload, cli_indent, cli_me)
@@ -1,8 +1,6 @@
1
1
  import click
2
2
  from click import ClickException
3
3
  from scim2_client import Me
4
- from scim2_models import Message
5
- from scim2_models import Resource
6
4
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
7
5
 
8
6
  from scim2_cli.utils import exception_to_click_error
@@ -11,7 +9,7 @@ from scim2_cli.utils import me_option
11
9
 
12
10
  from .utils import DOC_URL
13
11
  from .utils import SCIM_EXCEPTIONS
14
- from .utils import formatted_payload
12
+ from .utils import echo_response
15
13
 
16
14
 
17
15
  @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="delete")
@@ -50,21 +48,12 @@ def delete_cli(
50
48
  if not me and not (resource_type and id):
51
49
  raise ClickException("Pass a resource type and an id, or --me.")
52
50
 
53
- target = (
54
- find_target(ctx.obj["resource_types"], resource_type) if resource_type else Me
55
- )
51
+ target = find_target(ctx.obj.resource_types, resource_type) if resource_type else Me
56
52
 
57
53
  try:
58
- response = ctx.obj["client"].delete(target, id, raise_scim_errors=False)
54
+ response = ctx.obj.client.delete(target, id, raise_scim_errors=False)
59
55
 
60
56
  except SCIM_EXCEPTIONS as scim_exc:
61
57
  raise exception_to_click_error(scim_exc) from scim_exc
62
58
 
63
- if response:
64
- payload = (
65
- response.model_dump()
66
- if isinstance(response, Resource | Message)
67
- else response
68
- )
69
- payload = formatted_payload(payload, indent)
70
- click.echo(payload)
59
+ echo_response(response, indent)
@@ -0,0 +1,209 @@
1
+ import json
2
+ from decimal import Decimal
3
+ from typing import Any
4
+
5
+ import click
6
+ from click import ClickException
7
+ from pydantic import ValidationError
8
+ from scim2_client import Me
9
+ from scim2_models import BaseModel
10
+ from scim2_models import Context
11
+ from scim2_models import Error
12
+ from scim2_models import InvalidPathException
13
+ from scim2_models import PatchOp
14
+ from scim2_models import Path
15
+ from scim2_models import Resource
16
+ from scim2_models import SCIMException
17
+ from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
18
+
19
+ from scim2_cli.utils import DOC_URL
20
+ from scim2_cli.utils import SCIM_EXCEPTIONS
21
+ from scim2_cli.utils import echo_response
22
+ from scim2_cli.utils import escape_control_characters
23
+ from scim2_cli.utils import exception_to_click_error
24
+ from scim2_cli.utils import find_target
25
+ from scim2_cli.utils import indent_option
26
+ from scim2_cli.utils import me_option
27
+
28
+ OPERATIONS = {"add": 2, "replace": 2, "remove": 1}
29
+ JSON_TYPES = (bool, int, float, Decimal, BaseModel)
30
+
31
+
32
+ def parse_operations(tokens: tuple[str, ...]) -> list[tuple[str, str, str | None]]:
33
+ """Split the arguments into operations, each one followed by its path and its value."""
34
+ operations: list[tuple[str, str, str | None]] = []
35
+ index = 0
36
+ while index < len(tokens):
37
+ op = tokens[index].lower()
38
+ if op not in OPERATIONS:
39
+ raise ClickException(
40
+ f"Unknown operation '{escape_control_characters(tokens[index])}'. "
41
+ f"Available values are: {', '.join(OPERATIONS)}"
42
+ )
43
+
44
+ nargs = OPERATIONS[op]
45
+ arguments = tokens[index + 1 : index + 1 + nargs]
46
+ if len(arguments) < nargs or (op == "remove" and not arguments[0]):
47
+ expected = "a path and a value" if nargs == 2 else "a path"
48
+ raise ClickException(f"The '{op}' operation takes {expected}.")
49
+
50
+ path, *value = arguments
51
+ operations.append((op, path, value[0] if value else None))
52
+ index += 1 + nargs
53
+
54
+ return operations
55
+
56
+
57
+ def serves(model: type[Resource[Any]], path: str) -> bool:
58
+ """Tell whether a path designates an attribute or an extension of a model."""
59
+ bound = Path[model](path) # type: ignore[valid-type]
60
+ return bound.resolve() is not None or bound.model is not None
61
+
62
+
63
+ def is_json(model: type[Resource[Any]], path: str) -> bool:
64
+ """Tell whether the value of an attribute is written in JSON rather than as a plain string."""
65
+ bound = Path[model](path) # type: ignore[valid-type]
66
+ binding = bound.resolve()
67
+ if binding is None:
68
+ return True
69
+
70
+ if binding.is_multivalued and not binding.sub_field_name and not bound.value_filter:
71
+ return True
72
+
73
+ target = binding.target_type
74
+ return isinstance(target, type) and issubclass(target, JSON_TYPES)
75
+
76
+
77
+ def read_value(model: type[Resource[Any]], path: str, value: str) -> Any:
78
+ """Read a value with the type of the attribute it is meant for."""
79
+ if not is_json(model, path):
80
+ return value
81
+
82
+ try:
83
+ return json.loads(value)
84
+ except json.JSONDecodeError as exc:
85
+ raise ClickException(
86
+ f"Invalid JSON value for '{escape_control_characters(path)}': {exc}"
87
+ ) from exc
88
+
89
+
90
+ def build_patch(
91
+ models: list[type[Resource[Any]]], operations: list[tuple[str, str, str | None]]
92
+ ) -> PatchOp[Resource[Any]]:
93
+ """Build the patch operation of the first model that has all the attributes."""
94
+ paths = [path for _, path, _ in operations]
95
+ for path in paths:
96
+ try:
97
+ Path(path)
98
+ except InvalidPathException as exc:
99
+ raise ClickException(
100
+ f"Invalid path '{escape_control_characters(path)}': {exc}"
101
+ ) from exc
102
+
103
+ model = next(
104
+ (model for model in models if all(serves(model, path) for path in paths)),
105
+ None,
106
+ )
107
+ if model is None:
108
+ unknown = [
109
+ path for path in paths if not any(serves(model, path) for model in models)
110
+ ]
111
+ message = (
112
+ "Unknown attributes: "
113
+ if unknown
114
+ else "No resource type has all the attributes: "
115
+ )
116
+ raise ClickException(
117
+ message
118
+ + ", ".join(escape_control_characters(path) for path in unknown or paths)
119
+ )
120
+
121
+ patch_operations = []
122
+ for op, path, value in operations:
123
+ operation = {"op": op}
124
+ if path:
125
+ operation["path"] = path
126
+ if value is not None:
127
+ operation["value"] = read_value(model, path, value)
128
+ patch_operations.append(operation)
129
+
130
+ try:
131
+ return PatchOp[model].model_validate({"Operations": patch_operations}) # type: ignore[valid-type]
132
+ except ValidationError as exc:
133
+ error = Error.from_validation_errors(exc)[0]
134
+ raise exception_to_click_error(
135
+ SCIMException.from_error(error, scim_ctx=Context.RESOURCE_PATCH_REQUEST)
136
+ ) from exc
137
+
138
+
139
+ @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="modify")
140
+ @click.argument("arguments", nargs=-1, metavar="[RESOURCE_TYPE ID] OPERATION...")
141
+ @me_option()
142
+ @indent_option()
143
+ @click.pass_context
144
+ def modify_cli(
145
+ ctx: click.Context, arguments: tuple[str, ...], me: bool, indent: bool
146
+ ) -> None:
147
+ r"""Perform a `SCIM PATCH <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.2>`_ request.
148
+
149
+ The operations follow the resource type and the id. They are applied in order:
150
+
151
+ - :code:`add PATH VALUE` adds a value,
152
+ - :code:`replace PATH VALUE` replaces a value,
153
+ - :code:`remove PATH` removes a value.
154
+
155
+ .. code-block:: bash
156
+
157
+ modify user 1234 replace displayName "Barbara Jensen" \
158
+ remove 'emails[type eq "work"]' \
159
+ add emails '[{"value": "bjensen@example.com", "type": "work"}]'
160
+
161
+ Values of string attributes are passed as is, the other values are passed in JSON.
162
+ An empty path applies a JSON object of attributes to the resource.
163
+ Pass :code:`--` before the operations if a value starts with :code:`-`.
164
+
165
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint:
166
+
167
+ .. code-block:: bash
168
+
169
+ modify --me replace displayName "Barbara Jensen"
170
+
171
+ A patch operation can also be passed through stdin in JSON format:
172
+
173
+ .. code-block:: bash
174
+
175
+ echo '{"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{"op": "replace", "value": {"displayName": "Barbara Jensen"}}]}' | modify user 1234
176
+
177
+ """
178
+ resource_types = ctx.obj.resource_types
179
+ provider = ctx.obj.client.provider
180
+ if me and arguments and arguments[0].lower() in resource_types:
181
+ raise ClickException("--me cannot be used with a resource type or an id.")
182
+
183
+ if me:
184
+ target, id, tokens = Me, None, arguments
185
+ models = list(
186
+ dict.fromkeys(provider.model_for(rt) for rt in resource_types.values())
187
+ )
188
+ elif len(arguments) < 2:
189
+ raise ClickException("Pass a resource type and an id, or --me.")
190
+ else:
191
+ target = find_target(resource_types, arguments[0])
192
+ id, tokens = arguments[1], arguments[2:]
193
+ models = [provider.model_for(target)]
194
+
195
+ stdin = ctx.obj.stdin
196
+ if stdin and tokens:
197
+ raise ClickException("Pass the operations either as arguments or to stdin.")
198
+
199
+ if not stdin and not tokens:
200
+ raise ClickException("Missing operation.")
201
+
202
+ payload = stdin or build_patch(models, parse_operations(tokens))
203
+ try:
204
+ response = ctx.obj.client.modify(target, id, payload, raise_scim_errors=False)
205
+
206
+ except SCIM_EXCEPTIONS as scim_exc:
207
+ raise exception_to_click_error(scim_exc) from scim_exc
208
+
209
+ echo_response(response, indent)
@@ -17,7 +17,7 @@ from scim2_cli.utils import me_option
17
17
 
18
18
  from .utils import DOC_URL
19
19
  from .utils import SCIM_EXCEPTIONS
20
- from .utils import formatted_payload
20
+ from .utils import echo_response
21
21
 
22
22
  DISCOVERY_MODELS = (Schema, ResourceType, ServiceProviderConfig)
23
23
 
@@ -106,7 +106,7 @@ def query_cli(
106
106
  target: ResourceType | type[Resource[Any]] | None = None
107
107
  if resource_type:
108
108
  targets: dict[str, ResourceType | type[Resource[Any]]] = {
109
- **ctx.obj["resource_types"],
109
+ **ctx.obj.resource_types,
110
110
  **{model.__name__.lower(): model for model in DISCOVERY_MODELS},
111
111
  }
112
112
  target = find_target(targets, resource_type)
@@ -129,9 +129,9 @@ def query_cli(
129
129
  f"{', '.join(listing_options)} cannot be used when querying a single resource."
130
130
  )
131
131
 
132
- if ctx.obj.get("stdin"):
132
+ if ctx.obj.stdin:
133
133
  check_request_payload = False
134
- payload = ctx.obj.get("stdin")
134
+ payload = ctx.obj.stdin
135
135
 
136
136
  elif single_resource:
137
137
  check_request_payload = True
@@ -158,7 +158,7 @@ def query_cli(
158
158
  )
159
159
 
160
160
  try:
161
- response = ctx.obj["client"].query(
161
+ response = ctx.obj.client.query(
162
162
  Me if me else target,
163
163
  id,
164
164
  query_parameters=payload,
@@ -169,5 +169,4 @@ def query_cli(
169
169
  except SCIM_EXCEPTIONS as scim_exc:
170
170
  raise exception_to_click_error(scim_exc) from scim_exc
171
171
 
172
- payload = formatted_payload(response.model_dump(), indent)
173
- click.echo(payload)
172
+ echo_response(response, indent)
@@ -21,7 +21,7 @@ from scim2_cli.utils import me_option
21
21
  from .utils import DOC_URL
22
22
  from .utils import SCIM_EXCEPTIONS
23
23
  from .utils import ModelCommand
24
- from .utils import formatted_payload
24
+ from .utils import echo_response
25
25
  from .utils import renamed_fields
26
26
  from .utils import unacceptable_fields
27
27
 
@@ -45,7 +45,7 @@ def replace_payload(
45
45
  except SCIM_EXCEPTIONS as scim_exc:
46
46
  raise exception_to_click_error(scim_exc) from scim_exc
47
47
 
48
- click.echo(formatted_payload(response.model_dump(), indent))
48
+ echo_response(response, indent)
49
49
 
50
50
 
51
51
  def replace_factory(
@@ -82,9 +82,9 @@ def replace_factory(
82
82
 
83
83
  .. code-block:: bash
84
84
 
85
- replace user \\
86
- --id "xxxx-yyyy" \\
87
- --user-name "foo" \\
85
+ replace user \
86
+ --id "xxxx-yyyy" \
87
+ --user-name "foo" \
88
88
  --emails '[{"value":"foo@bar.example", "primary": true}, {"value": "foo@baz.example"}]'
89
89
 
90
90
  Input can also be passed through stdin in JSON format:
@@ -104,13 +104,13 @@ def replace_factory(
104
104
  if obj == model():
105
105
  obj = None
106
106
 
107
- payload = ctx.obj.get("stdin") or obj
107
+ payload = ctx.obj.stdin or obj
108
108
  if not payload:
109
109
  click.echo(ctx.get_help())
110
110
  ctx.exit(1)
111
111
 
112
112
  replace_payload(
113
- ctx.obj["client"],
113
+ ctx.obj.client,
114
114
  resource_type,
115
115
  payload,
116
116
  inherited(ctx, "cli_indent", cli_indent),
@@ -152,9 +152,9 @@ def replace_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
152
152
  if ctx.invoked_subcommand is not None:
153
153
  return
154
154
 
155
- payload = ctx.obj.get("stdin")
155
+ payload = ctx.obj.stdin
156
156
  if not payload:
157
157
  click.echo(ctx.get_help())
158
158
  ctx.exit(1)
159
159
 
160
- replace_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
160
+ replace_payload(ctx.obj.client, None, payload, cli_indent, cli_me)
@@ -7,7 +7,7 @@ from scim2_cli.utils import find_target
7
7
 
8
8
  from .utils import DOC_URL
9
9
  from .utils import SCIM_EXCEPTIONS
10
- from .utils import formatted_payload
10
+ from .utils import echo_response
11
11
 
12
12
 
13
13
  @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="search")
@@ -80,12 +80,12 @@ def search_cli(
80
80
 
81
81
  """
82
82
  target = (
83
- find_target(ctx.obj["resource_types"], resource_type) if resource_type else None
83
+ find_target(ctx.obj.resource_types, resource_type) if resource_type else None
84
84
  )
85
85
 
86
- if ctx.obj.get("stdin"):
86
+ if ctx.obj.stdin:
87
87
  check_request_payload = False
88
- payload = ctx.obj.get("stdin")
88
+ payload = ctx.obj.stdin
89
89
 
90
90
  else:
91
91
  check_request_payload = True
@@ -103,7 +103,7 @@ def search_cli(
103
103
  )
104
104
 
105
105
  try:
106
- response = ctx.obj["client"].search(
106
+ response = ctx.obj.client.search(
107
107
  target,
108
108
  search_request=payload,
109
109
  check_request_payload=check_request_payload,
@@ -113,5 +113,4 @@ def search_cli(
113
113
  except SCIM_EXCEPTIONS as scim_exc:
114
114
  raise exception_to_click_error(scim_exc) from scim_exc
115
115
 
116
- payload = formatted_payload(response.model_dump(), indent)
117
- click.echo(payload)
116
+ echo_response(response, indent)
@@ -0,0 +1,132 @@
1
+ import json
2
+ import sys
3
+ from functools import cached_property
4
+ from typing import IO
5
+ from typing import Any
6
+ from typing import TypeVar
7
+ from typing import cast
8
+
9
+ from click import ClickException
10
+ from httpx2 import Client
11
+ from scim2_client.engines.httpx2 import SyncSCIMClient
12
+ from scim2_models import ListResponse
13
+ from scim2_models import Resource
14
+ from scim2_models import ResourceType
15
+ from scim2_models import Schema
16
+ from scim2_models import ScimProvider
17
+ from scim2_models import ScimProviderError
18
+ from scim2_models import ServiceProviderConfig
19
+
20
+ from scim2_cli.utils import SCIM_EXCEPTIONS
21
+ from scim2_cli.utils import command_name
22
+ from scim2_cli.utils import escape_control_characters
23
+ from scim2_cli.utils import exception_to_click_error
24
+
25
+ ResourceT = TypeVar("ResourceT", bound=Resource[Any])
26
+
27
+
28
+ def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
29
+ """Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
30
+ payload = json.load(fd)
31
+ if isinstance(payload, dict):
32
+ list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
33
+ model
34
+ ].model_validate(payload)
35
+ return list_response.resources or []
36
+ return [model.model_validate(item) for item in payload]
37
+
38
+
39
+ def describe_server(
40
+ scim_client: SyncSCIMClient,
41
+ schemas_fd: IO[str] | None,
42
+ resource_types_fd: IO[str] | None,
43
+ service_provider_config_fd: IO[str] | None,
44
+ ) -> ScimProvider:
45
+ """Describe the server with the configuration files, and query it for the others."""
46
+ resource_types = (
47
+ load_objects(resource_types_fd, ResourceType)
48
+ if resource_types_fd
49
+ else cast(
50
+ "ListResponse[ResourceType]", scim_client.query(ResourceType)
51
+ ).resources
52
+ or []
53
+ )
54
+ schemas = (
55
+ load_objects(schemas_fd, Schema)
56
+ if schemas_fd
57
+ else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
58
+ )
59
+ config = (
60
+ ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
61
+ if service_provider_config_fd
62
+ else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
63
+ )
64
+ return ScimProvider.from_discovery(schemas, resource_types, config)
65
+
66
+
67
+ class Session:
68
+ """What the commands share: the server, discovered on first use, and the standard input."""
69
+
70
+ def __init__(
71
+ self,
72
+ url: str | None,
73
+ headers: dict[str, str],
74
+ verify: bool,
75
+ schemas: IO[str] | None,
76
+ resource_types: IO[str] | None,
77
+ service_provider_config: IO[str] | None,
78
+ ) -> None:
79
+ self.url = url
80
+ self.headers = headers
81
+ self.verify = verify
82
+ self.schemas = schemas
83
+ self.resource_types_file = resource_types
84
+ self.service_provider_config = service_provider_config
85
+
86
+ @cached_property
87
+ def client(self) -> SyncSCIMClient:
88
+ """The client of the server, described by the configuration files and the discovery."""
89
+ if not self.url:
90
+ raise ClickException("No SCIM server URL defined.")
91
+
92
+ client = SyncSCIMClient(
93
+ Client(base_url=self.url, headers=self.headers, verify=self.verify)
94
+ )
95
+ try:
96
+ client.provider = describe_server(
97
+ client,
98
+ self.schemas,
99
+ self.resource_types_file,
100
+ self.service_provider_config,
101
+ )
102
+ except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
103
+ error = exception_to_click_error(exc)
104
+ raise ClickException(
105
+ f"Could not discover the server at {escape_control_characters(self.url)}: "
106
+ f"{error.message}"
107
+ ) from exc
108
+ return client
109
+
110
+ @cached_property
111
+ def resource_types(self) -> dict[str, ResourceType]:
112
+ """The resource types of the server, by command name."""
113
+ return {
114
+ command_name(resource_type): resource_type
115
+ for resource_type in self.client.provider.resource_types
116
+ if resource_type.name or resource_type.id
117
+ }
118
+
119
+ @cached_property
120
+ def stdin(self) -> Any:
121
+ """The JSON payload passed to the standard input, if any."""
122
+ if sys.stdin.isatty(): # pragma: no cover
123
+ return None
124
+
125
+ payload = sys.stdin.read().strip()
126
+ if not payload:
127
+ return None
128
+
129
+ try:
130
+ return json.loads(payload)
131
+ except json.JSONDecodeError as exc:
132
+ raise ClickException(f"Invalid JSON input.\n{exc}") from exc
@@ -34,7 +34,7 @@ def test_cli(
34
34
 
35
35
  test
36
36
  """
37
- client = ctx.obj["client"]
37
+ client = ctx.obj.client
38
38
  client.check_response_status_codes = check_status_code
39
39
  client.check_response_content_type = check_content_type
40
40
  results = check_server(client)
@@ -12,7 +12,9 @@ from click import ClickException
12
12
  from click.core import ParameterSource
13
13
  from scim2_client import SCIMClientException
14
14
  from scim2_models import BaseModel
15
+ from scim2_models import BulkResponse
15
16
  from scim2_models import Context
17
+ from scim2_models import Error
16
18
  from scim2_models import Mutability
17
19
  from scim2_models import Resource
18
20
  from scim2_models import ResourceType
@@ -129,6 +131,30 @@ def formatted_payload(obj: Any, indent: bool) -> str:
129
131
  return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
130
132
 
131
133
 
134
+ def echo_response(response: Any, indent: bool) -> None:
135
+ """Display the response of the server, and exit with an error code if it reports a failure."""
136
+ if response is None:
137
+ return
138
+
139
+ click.echo(formatted_payload(response.model_dump(), indent))
140
+ if isinstance(response, Error):
141
+ summary = " ".join(
142
+ str(part) for part in (response.status, response.detail) if part
143
+ )
144
+ click.echo(f"Error: {escape_control_characters(summary)}", err=True)
145
+ click.get_current_context().exit(1)
146
+
147
+ if isinstance(response, BulkResponse):
148
+ operations = response.operations or []
149
+ failed = [op for op in operations if op.status and op.status >= 400]
150
+ if failed:
151
+ click.echo(
152
+ f"Error: {len(failed)} of {len(operations)} bulk operations failed",
153
+ err=True,
154
+ )
155
+ click.get_current_context().exit(1)
156
+
157
+
132
158
  def split_headers(headers: list[str]) -> dict[str, str]:
133
159
  """Make a dict from header strings.
134
160
 
@@ -155,16 +181,16 @@ class ModelCommand(RstToAnsiGroup):
155
181
  self.factory = factory
156
182
 
157
183
  def list_commands(self, ctx: click.Context) -> list[str]:
158
- ctx.ensure_object(dict)
159
184
  base = super().list_commands(ctx)
160
- lazy = sorted(ctx.obj.get("resource_types", {}).keys())
161
- return base + lazy
185
+ if ctx.obj is None:
186
+ return base
187
+ return base + sorted(ctx.obj.resource_types)
162
188
 
163
189
  def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
164
- resource_type = ctx.obj["resource_types"].get(cmd_name.lower())
190
+ resource_type = ctx.obj.resource_types.get(cmd_name.lower())
165
191
  if resource_type is None:
166
192
  return None
167
- model = ctx.obj["client"].provider.model_for(resource_type)
193
+ model = ctx.obj.client.provider.model_for(resource_type)
168
194
  return self.factory(resource_type, model)
169
195
 
170
196
 
@@ -1,163 +0,0 @@
1
- import json
2
- import sys
3
- from typing import IO
4
- from typing import Any
5
- from typing import TypeVar
6
- from typing import cast
7
-
8
- import click
9
- from httpx2 import Client
10
- from scim2_client.engines.httpx2 import SyncSCIMClient
11
- from scim2_models import ListResponse
12
- from scim2_models import Resource
13
- from scim2_models import ResourceType
14
- from scim2_models import Schema
15
- from scim2_models import ScimProvider
16
- from scim2_models import ScimProviderError
17
- from scim2_models import ServiceProviderConfig
18
- from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
19
-
20
- from scim2_cli.create import create_cli
21
- from scim2_cli.delete import delete_cli
22
- from scim2_cli.query import query_cli
23
- from scim2_cli.replace import replace_cli
24
- from scim2_cli.search import search_cli
25
- from scim2_cli.test import test_cli
26
- from scim2_cli.utils import DOC_URL
27
- from scim2_cli.utils import SCIM_EXCEPTIONS
28
- from scim2_cli.utils import HeaderType
29
- from scim2_cli.utils import command_name
30
- from scim2_cli.utils import exception_to_click_error
31
- from scim2_cli.utils import split_headers
32
-
33
- ResourceT = TypeVar("ResourceT", bound=Resource[Any])
34
-
35
-
36
- def load_objects(fd: IO[str], model: type[ResourceT]) -> list[ResourceT]:
37
- """Read a list of objects, bare or wrapped in a ListResponse, from a JSON file."""
38
- payload = json.load(fd)
39
- if isinstance(payload, dict):
40
- list_response: ListResponse[ResourceT] = cast(Any, ListResponse)[
41
- model
42
- ].model_validate(payload)
43
- return list_response.resources or []
44
- return [model.model_validate(item) for item in payload]
45
-
46
-
47
- def describe_server(
48
- scim_client: SyncSCIMClient,
49
- schemas_fd: IO[str] | None,
50
- resource_types_fd: IO[str] | None,
51
- service_provider_config_fd: IO[str] | None,
52
- ) -> ScimProvider:
53
- """Describe the server with the configuration files, and query it for the others."""
54
- resource_types = (
55
- load_objects(resource_types_fd, ResourceType)
56
- if resource_types_fd
57
- else cast(
58
- "ListResponse[ResourceType]", scim_client.query(ResourceType)
59
- ).resources
60
- or []
61
- )
62
- schemas = (
63
- load_objects(schemas_fd, Schema)
64
- if schemas_fd
65
- else cast("ListResponse[Schema]", scim_client.query(Schema)).resources or []
66
- )
67
- config = (
68
- ServiceProviderConfig.model_validate(json.load(service_provider_config_fd))
69
- if service_provider_config_fd
70
- else cast(ServiceProviderConfig, scim_client.query(ServiceProviderConfig))
71
- )
72
- return ScimProvider.from_discovery(schemas, resource_types, config)
73
-
74
-
75
- @click.group(cls=make_rst_to_ansi_formatter(DOC_URL, group=True))
76
- @click.option("-u", "--url", help="The SCIM server endpoint.", envvar="SCIM_CLI_URL")
77
- @click.option(
78
- "-h",
79
- "--header",
80
- multiple=True,
81
- type=HeaderType(),
82
- help="Headers to pass in the HTTP requests. Can be passed multiple times. Other users of the machine can see the command line arguments, so pass the secrets with the SCIM_CLI_HEADERS environment variable.",
83
- envvar="SCIM_CLI_HEADERS",
84
- )
85
- @click.option(
86
- "--no-verify",
87
- is_flag=True,
88
- default=False,
89
- help="Don't perform https certificate verifications.",
90
- )
91
- @click.option(
92
- "-s",
93
- "--schemas",
94
- type=click.File(),
95
- help="Path to a JSON file containing a list of SCIM Schemas. Those schemas will be assumed to be available on the server. If unset, they will be downloaded.",
96
- envvar="SCIM_CLI_SCHEMAS",
97
- )
98
- @click.option(
99
- "-r",
100
- "--resource-types",
101
- type=click.File(),
102
- help="Path to a JSON file containing a list of SCIM ResourceType. Those resource types will be assumed to be available on the server. If unset, they will be downloaded.",
103
- envvar="SCIM_CLI_RESOURCE_TYPES",
104
- )
105
- @click.option(
106
- "-c",
107
- "--service-provider-config",
108
- type=click.File(),
109
- help="Path to a JSON file containing the ServiceProviderConfig content of the server. Will be downloaded otherwise.",
110
- envvar="SCIM_CLI_SERVICE_PROVIDER_CONFIG",
111
- )
112
- @click.pass_context
113
- def cli(
114
- ctx: click.Context,
115
- url: str | None,
116
- header: list[str],
117
- no_verify: bool,
118
- schemas: IO[str] | None,
119
- resource_types: IO[str] | None,
120
- service_provider_config: IO[str] | None,
121
- ) -> None:
122
- """SCIM application development CLI."""
123
- ctx.ensure_object(dict)
124
-
125
- if not url:
126
- raise click.ClickException("No SCIM server URL defined.")
127
-
128
- headers_dict = split_headers(header)
129
- client = Client(base_url=url, headers=headers_dict, verify=not no_verify)
130
-
131
- scim_client = SyncSCIMClient(client)
132
- try:
133
- scim_client.provider = describe_server(
134
- scim_client, schemas, resource_types, service_provider_config
135
- )
136
- except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
137
- raise exception_to_click_error(exc) from exc
138
-
139
- ctx.obj["client"] = scim_client
140
- ctx.obj["resource_types"] = {
141
- command_name(resource_type): resource_type
142
- for resource_type in scim_client.provider.resource_types
143
- if resource_type.name or resource_type.id
144
- }
145
-
146
- if not sys.stdin.isatty(): # pragma: no cover
147
- if stdin := sys.stdin.read().strip():
148
- try:
149
- ctx.obj["stdin"] = json.loads(stdin)
150
- except json.JSONDecodeError as exc:
151
- message = f"Invalid JSON input.\n{exc}"
152
- raise click.ClickException(message) from exc
153
-
154
-
155
- cli.add_command(create_cli)
156
- cli.add_command(query_cli)
157
- cli.add_command(replace_cli)
158
- cli.add_command(delete_cli)
159
- cli.add_command(search_cli)
160
- cli.add_command(test_cli)
161
-
162
- if __name__ == "__main__": # pragma: no cover
163
- cli()
File without changes
File without changes