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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-cli
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: SCIM application development CLI
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop
@@ -217,8 +217,8 @@ Classifier: License :: OSI Approved :: Apache Software License
217
217
  Classifier: Environment :: Web Environment
218
218
  Classifier: Programming Language :: Python
219
219
  Classifier: Operating System :: OS Independent
220
- Requires-Dist: click>=8.1.7
221
- Requires-Dist: scim2-client[httpx2]>=0.11.2
220
+ Requires-Dist: click>=8.5.0
221
+ Requires-Dist: scim2-client[httpx2]>=0.13.0
222
222
  Requires-Dist: scim2-tester>=0.4.0
223
223
  Requires-Dist: sphinx-click-rst-to-ansi-formatter>=0.2.0
224
224
  Requires-Dist: pydanclick>=0.5.0
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "SCIM application development CLI"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -29,8 +29,8 @@ classifiers = [
29
29
  ]
30
30
  requires-python = ">= 3.11"
31
31
  dependencies = [
32
- "click>=8.1.7",
33
- "scim2-client[httpx2]>=0.11.2",
32
+ "click>=8.5.0",
33
+ "scim2-client[httpx2]>=0.13.0",
34
34
  "scim2-tester>=0.4.0",
35
35
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
36
36
  "pydanclick>=0.5.0",
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-cli"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "SCIM application development CLI"
9
9
  authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
10
10
  license = {file = "LICENSE.md"}
@@ -26,8 +26,8 @@ classifiers = [
26
26
 
27
27
  requires-python = ">= 3.11"
28
28
  dependencies = [
29
- "click>=8.1.7",
30
- "scim2-client[httpx2]>=0.11.2",
29
+ "click>=8.5.0",
30
+ "scim2-client[httpx2]>=0.13.0",
31
31
  "scim2-tester>=0.4.0",
32
32
  "sphinx-click-rst-to-ansi-formatter>=0.2.0",
33
33
  "pydanclick>=0.5.0",
@@ -1,5 +1,4 @@
1
1
  import json
2
- import re
3
2
  import sys
4
3
  from typing import IO
5
4
  from typing import Any
@@ -27,7 +26,7 @@ from scim2_cli.test import test_cli
27
26
  from scim2_cli.utils import DOC_URL
28
27
  from scim2_cli.utils import SCIM_EXCEPTIONS
29
28
  from scim2_cli.utils import HeaderType
30
- from scim2_cli.utils import escape_control_characters
29
+ from scim2_cli.utils import command_name
31
30
  from scim2_cli.utils import exception_to_click_error
32
31
  from scim2_cli.utils import split_headers
33
32
 
@@ -137,14 +136,12 @@ def cli(
137
136
  except (*SCIM_EXCEPTIONS, ScimProviderError) as exc:
138
137
  raise exception_to_click_error(exc) from exc
139
138
 
140
- provider = scim_client.provider
141
139
  ctx.obj["client"] = scim_client
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
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
+ }
148
145
 
149
146
  if not sys.stdin.isatty(): # pragma: no cover
150
147
  if stdin := sys.stdin.read().strip():
@@ -2,30 +2,43 @@ from typing import Any
2
2
  from typing import cast
3
3
 
4
4
  import click
5
- from click import ClickException
6
5
  from pydanclick import from_pydantic
6
+ from scim2_client import Me
7
7
  from scim2_client.engines.httpx2 import SyncSCIMClient
8
8
  from scim2_models import Context
9
9
  from scim2_models import Error
10
10
  from scim2_models import Resource
11
+ from scim2_models import ResourceType
11
12
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
12
13
 
13
14
  from scim2_cli.utils import DOC_URL
14
15
  from scim2_cli.utils import SCIM_EXCEPTIONS
15
16
  from scim2_cli.utils import ModelCommand
17
+ from scim2_cli.utils import command_name
16
18
  from scim2_cli.utils import escape_options_help
17
19
  from scim2_cli.utils import exception_to_click_error
18
20
  from scim2_cli.utils import formatted_payload
21
+ from scim2_cli.utils import indent_option
22
+ from scim2_cli.utils import inherited
23
+ from scim2_cli.utils import me_option
24
+ from scim2_cli.utils import renamed_fields
19
25
  from scim2_cli.utils import unacceptable_fields
20
26
 
21
27
 
22
28
  def create_payload(
23
- client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
29
+ client: SyncSCIMClient,
30
+ resource_type: ResourceType | None,
31
+ payload: Resource[Any] | dict[str, Any],
32
+ indent: bool,
33
+ me: bool,
24
34
  ) -> None:
25
35
  try:
26
36
  # Response payloads are always checked, so the client never returns a dict.
27
37
  response = cast(
28
- "Resource[Any] | Error", client.create(payload, raise_scim_errors=False)
38
+ "Resource[Any] | Error",
39
+ client.create(
40
+ Me if me else resource_type, payload, raise_scim_errors=False
41
+ ),
29
42
  )
30
43
 
31
44
  except SCIM_EXCEPTIONS as scim_exc:
@@ -34,27 +47,23 @@ def create_payload(
34
47
  click.echo(formatted_payload(response.model_dump(), indent))
35
48
 
36
49
 
37
- def create_factory(model: type[Resource[Any]] | None) -> click.Command:
38
- if not model:
39
- raise ClickException("Invalid model")
40
-
50
+ def create_factory(
51
+ resource_type: ResourceType, model: type[Resource[Any]]
52
+ ) -> click.Command:
41
53
  exclude = unacceptable_fields(Context.RESOURCE_CREATION_REQUEST, model)
42
54
 
43
55
  @click.command(
44
56
  cls=make_rst_to_ansi_formatter(DOC_URL),
45
- name=model.__name__.lower(),
46
- )
47
- @click.option(
48
- "--indent/--no-indent",
49
- is_flag=True,
50
- default=True,
51
- help="Indent JSON response payloads.",
57
+ name=command_name(resource_type),
52
58
  )
53
- @from_pydantic("obj", model, exclude=exclude)
59
+ @indent_option("cli_indent")
60
+ @me_option("cli_me")
61
+ @from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
54
62
  @click.pass_context
55
63
  def create_command(
56
64
  ctx: click.Context,
57
- indent: bool,
65
+ cli_indent: bool,
66
+ cli_me: bool,
58
67
  obj: Resource[Any] | None,
59
68
  *args: Any,
60
69
  **kwargs: Any,
@@ -81,6 +90,13 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
81
90
 
82
91
  echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create user
83
92
 
93
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
94
+ and the server chooses the resource type:
95
+
96
+ .. code-block:: bash
97
+
98
+ scim create user --me --user-name "foo"
99
+
84
100
  """
85
101
  if obj == model():
86
102
  obj = None
@@ -90,7 +106,13 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
90
106
  click.echo(ctx.get_help())
91
107
  ctx.exit(1)
92
108
 
93
- create_payload(ctx.obj["client"], payload, indent)
109
+ create_payload(
110
+ ctx.obj["client"],
111
+ resource_type,
112
+ payload,
113
+ inherited(ctx, "cli_indent", cli_indent),
114
+ inherited(ctx, "cli_me", cli_me),
115
+ )
94
116
 
95
117
  return escape_options_help(create_command)
96
118
 
@@ -102,16 +124,12 @@ def create_factory(model: type[Resource[Any]] | None) -> click.Command:
102
124
  invoke_without_command=True,
103
125
  )
104
126
  @click.pass_context
105
- @click.option(
106
- "--indent/--no-indent",
107
- is_flag=True,
108
- default=True,
109
- help="Indent JSON response payloads.",
110
- )
111
- def create_cli(ctx: click.Context, indent: bool) -> None:
127
+ @indent_option("cli_indent")
128
+ @me_option("cli_me")
129
+ def create_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
112
130
  """Perform a `SCIM POST <https://www.rfc-editor.org/rfc/rfc7644#section-3.3>`_ request on resources endpoint.
113
131
 
114
- There are subcommands for all the available models, with dynamic attributes.
132
+ There are subcommands for all the resource types of the server, with dynamic attributes.
115
133
  See the attributes for :code:`user` with:
116
134
 
117
135
  .. code-block:: bash
@@ -124,6 +142,9 @@ def create_cli(ctx: click.Context, indent: bool) -> None:
124
142
 
125
143
  echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | create
126
144
 
145
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
146
+ and the server chooses the resource type.
147
+
127
148
  """
128
149
  if ctx.invoked_subcommand is not None:
129
150
  return
@@ -133,4 +154,4 @@ def create_cli(ctx: click.Context, indent: bool) -> None:
133
154
  click.echo(ctx.get_help())
134
155
  ctx.exit(1)
135
156
 
136
- create_payload(ctx.obj["client"], payload, indent)
157
+ create_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
@@ -1,10 +1,13 @@
1
1
  import click
2
2
  from click import ClickException
3
+ from scim2_client import Me
3
4
  from scim2_models import Message
4
5
  from scim2_models import Resource
5
6
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
6
7
 
7
8
  from scim2_cli.utils import exception_to_click_error
9
+ from scim2_cli.utils import find_target
10
+ from scim2_cli.utils import me_option
8
11
 
9
12
  from .utils import DOC_URL
10
13
  from .utils import SCIM_EXCEPTIONS
@@ -12,8 +15,9 @@ from .utils import formatted_payload
12
15
 
13
16
 
14
17
  @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="delete")
15
- @click.argument("resource-type", required=True)
16
- @click.argument("id", required=True)
18
+ @click.argument("resource-type", required=False)
19
+ @click.argument("id", required=False)
20
+ @me_option()
17
21
  @click.option(
18
22
  "--indent/--no-indent",
19
23
  is_flag=True,
@@ -21,23 +25,37 @@ from .utils import formatted_payload
21
25
  help="Indent JSON response payloads.",
22
26
  )
23
27
  @click.pass_context
24
- def delete_cli(ctx: click.Context, resource_type: str, id: str, indent: bool) -> None:
28
+ def delete_cli(
29
+ ctx: click.Context,
30
+ resource_type: str | None,
31
+ id: str | None,
32
+ me: bool,
33
+ indent: bool,
34
+ ) -> None:
25
35
  """Perform a `SCIM DELETE query <https://www.rfc-editor.org/rfc/rfc7644#section-3.6>`_ request.
26
36
 
27
37
  .. code-block:: bash
28
38
 
29
39
  delete user 1234
40
+
41
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint:
42
+
43
+ .. code-block:: bash
44
+
45
+ delete --me
30
46
  """
31
- try:
32
- resource_model = ctx.obj["resource_models"][resource_type]
33
- except KeyError as exc:
34
- ok_values = ", ".join(ctx.obj["resource_models"])
35
- raise ClickException(
36
- f"Unknown resource type '{resource_type}'. Available values are: {ok_values}'"
37
- ) from exc
47
+ if me and (resource_type or id):
48
+ raise ClickException("--me cannot be used with a resource type or an id.")
49
+
50
+ if not me and not (resource_type and id):
51
+ raise ClickException("Pass a resource type and an id, or --me.")
52
+
53
+ target = (
54
+ find_target(ctx.obj["resource_types"], resource_type) if resource_type else Me
55
+ )
38
56
 
39
57
  try:
40
- response = ctx.obj["client"].delete(resource_model, id, raise_scim_errors=False)
58
+ response = ctx.obj["client"].delete(target, id, raise_scim_errors=False)
41
59
 
42
60
  except SCIM_EXCEPTIONS as scim_exc:
43
61
  raise exception_to_click_error(scim_exc) from scim_exc
@@ -1,5 +1,9 @@
1
+ from typing import Any
2
+
1
3
  import click
2
4
  from click import ClickException
5
+ from scim2_client import Me
6
+ from scim2_models import Resource
3
7
  from scim2_models import ResourceType
4
8
  from scim2_models import ResponseParameters
5
9
  from scim2_models import Schema
@@ -8,11 +12,15 @@ from scim2_models import ServiceProviderConfig
8
12
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
9
13
 
10
14
  from scim2_cli.utils import exception_to_click_error
15
+ from scim2_cli.utils import find_target
16
+ from scim2_cli.utils import me_option
11
17
 
12
18
  from .utils import DOC_URL
13
19
  from .utils import SCIM_EXCEPTIONS
14
20
  from .utils import formatted_payload
15
21
 
22
+ DISCOVERY_MODELS = (Schema, ResourceType, ServiceProviderConfig)
23
+
16
24
 
17
25
  @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="query")
18
26
  @click.pass_context
@@ -33,6 +41,10 @@ from .utils import formatted_payload
33
41
  type=int,
34
42
  help="An integer indicating the 1-based index of the first query result.",
35
43
  )
44
+ @click.option(
45
+ "--cursor",
46
+ help="The cursor of the page to read, for cursor-based pagination (RFC 9865). Pass an empty value to read the first page.",
47
+ )
36
48
  @click.option(
37
49
  "--count",
38
50
  type=int,
@@ -49,6 +61,7 @@ from .utils import formatted_payload
49
61
  "--sort-order",
50
62
  help="A string indicating the order in which the “sortBy” parameter is applied.",
51
63
  )
64
+ @me_option()
52
65
  @click.option(
53
66
  "--indent/--no-indent",
54
67
  is_flag=True,
@@ -62,10 +75,12 @@ def query_cli(
62
75
  attribute: list[str],
63
76
  excluded_attribute: list[str],
64
77
  start_index: int | None,
78
+ cursor: str | None,
65
79
  count: int | None,
66
80
  filter: str | None,
67
81
  sort_by: str | None,
68
82
  sort_order: str | None,
83
+ me: bool,
69
84
  indent: bool,
70
85
  ) -> None:
71
86
  """Perform a `SCIM GET <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_ request on the :code:`RESOURCE_TYPE` endpoint.
@@ -73,6 +88,7 @@ def query_cli(
73
88
  - If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is `1234`, then the request will made on the :code:`/Users/1234` endpoint.
74
89
  - If :code:`RESOURCE_TYPE` is :code:`user` and :code:`id` is not set, then the request will made on the :code:`/Users` endpoint.
75
90
  - If :code:`RESOURCE_TYPE` is not set, then the request will made on the :code:`/` endpoint.
91
+ - If :code:`--me` is set, then the request will be made on the :code:`/Me` endpoint.
76
92
 
77
93
  When a single resource is queried, only :code:`--attribute` and :code:`--excluded-attribute` are
78
94
  available, as defined in `RFC7644 §3.4.1 <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_.
@@ -84,24 +100,23 @@ def query_cli(
84
100
  echo '{"startIndex": 50, "count": 10}' | query user
85
101
 
86
102
  """
87
- for discovery_model in (Schema, ResourceType, ServiceProviderConfig):
88
- ctx.obj["resource_models"][discovery_model.__name__.lower()] = discovery_model
103
+ if me and (resource_type or id):
104
+ raise ClickException("--me cannot be used with a resource type or an id.")
89
105
 
90
- model = None
106
+ target: ResourceType | type[Resource[Any]] | None = None
91
107
  if resource_type:
92
- try:
93
- model = ctx.obj["resource_models"][resource_type]
94
- except KeyError as exc:
95
- ok_values = ", ".join(ctx.obj["resource_models"])
96
- raise ClickException(
97
- f"Unknown resource type '{resource_type}. Available values are: {ok_values}'"
98
- ) from exc
99
-
100
- single_resource = bool(id) or model is ServiceProviderConfig
108
+ targets: dict[str, ResourceType | type[Resource[Any]]] = {
109
+ **ctx.obj["resource_types"],
110
+ **{model.__name__.lower(): model for model in DISCOVERY_MODELS},
111
+ }
112
+ target = find_target(targets, resource_type)
113
+
114
+ single_resource = me or bool(id) or target is ServiceProviderConfig
101
115
  listing_options = [
102
116
  name
103
117
  for name, value in (
104
118
  ("--start-index", start_index),
119
+ ("--cursor", cursor),
105
120
  ("--count", count),
106
121
  ("--filter", filter),
107
122
  ("--sort-by", sort_by),
@@ -122,8 +137,8 @@ def query_cli(
122
137
  check_request_payload = True
123
138
  payload = ResponseParameters.model_validate(
124
139
  {
125
- "attributes": attribute,
126
- "excluded_attributes": excluded_attribute,
140
+ "attributes": attribute or None,
141
+ "excluded_attributes": excluded_attribute or None,
127
142
  }
128
143
  )
129
144
 
@@ -131,9 +146,10 @@ def query_cli(
131
146
  check_request_payload = True
132
147
  payload = SearchRequest.model_validate(
133
148
  {
134
- "attributes": attribute,
135
- "excluded_attributes": excluded_attribute,
149
+ "attributes": attribute or None,
150
+ "excluded_attributes": excluded_attribute or None,
136
151
  "start_index": start_index,
152
+ "cursor": cursor,
137
153
  "count": count,
138
154
  "filter": filter,
139
155
  "sort_by": sort_by,
@@ -143,7 +159,7 @@ def query_cli(
143
159
 
144
160
  try:
145
161
  response = ctx.obj["client"].query(
146
- model,
162
+ Me if me else target,
147
163
  id,
148
164
  query_parameters=payload,
149
165
  check_request_payload=check_request_payload,
@@ -2,31 +2,44 @@ from typing import Any
2
2
  from typing import cast
3
3
 
4
4
  import click
5
- from click import ClickException
6
5
  from pydanclick import from_pydantic
6
+ from scim2_client import Me
7
7
  from scim2_client.engines.httpx2 import SyncSCIMClient
8
8
  from scim2_models import Context
9
9
  from scim2_models import Error
10
10
  from scim2_models import Resource
11
+ from scim2_models import ResourceType
11
12
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
12
13
 
14
+ from scim2_cli.utils import command_name
13
15
  from scim2_cli.utils import escape_options_help
14
16
  from scim2_cli.utils import exception_to_click_error
17
+ from scim2_cli.utils import indent_option
18
+ from scim2_cli.utils import inherited
19
+ from scim2_cli.utils import me_option
15
20
 
16
21
  from .utils import DOC_URL
17
22
  from .utils import SCIM_EXCEPTIONS
18
23
  from .utils import ModelCommand
19
24
  from .utils import formatted_payload
25
+ from .utils import renamed_fields
20
26
  from .utils import unacceptable_fields
21
27
 
22
28
 
23
29
  def replace_payload(
24
- client: SyncSCIMClient, payload: Resource[Any] | dict[str, Any], indent: bool
30
+ client: SyncSCIMClient,
31
+ resource_type: ResourceType | None,
32
+ payload: Resource[Any] | dict[str, Any],
33
+ indent: bool,
34
+ me: bool,
25
35
  ) -> None:
26
36
  try:
27
37
  # Response payloads are always checked, so the client never returns a dict.
28
38
  response = cast(
29
- "Resource[Any] | Error", client.replace(payload, raise_scim_errors=False)
39
+ "Resource[Any] | Error",
40
+ client.replace(
41
+ Me if me else resource_type, payload, raise_scim_errors=False
42
+ ),
30
43
  )
31
44
 
32
45
  except SCIM_EXCEPTIONS as scim_exc:
@@ -35,28 +48,24 @@ def replace_payload(
35
48
  click.echo(formatted_payload(response.model_dump(), indent))
36
49
 
37
50
 
38
- def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
39
- if not model:
40
- raise ClickException("Invalid model")
41
-
51
+ def replace_factory(
52
+ resource_type: ResourceType, model: type[Resource[Any]]
53
+ ) -> click.Command:
42
54
  exclude = unacceptable_fields(Context.RESOURCE_REPLACEMENT_REQUEST, model)
43
55
  exclude.remove("id")
44
56
 
45
57
  @click.command(
46
58
  cls=make_rst_to_ansi_formatter(DOC_URL),
47
- name=model.__name__.lower(),
48
- )
49
- @click.option(
50
- "--indent/--no-indent",
51
- is_flag=True,
52
- default=True,
53
- help="Indent JSON response payloads.",
59
+ name=command_name(resource_type),
54
60
  )
55
- @from_pydantic("obj", model, exclude=exclude)
61
+ @indent_option("cli_indent")
62
+ @me_option("cli_me")
63
+ @from_pydantic("obj", model, exclude=exclude, rename=renamed_fields(model, exclude))
56
64
  @click.pass_context
57
65
  def replace_command(
58
66
  ctx: click.Context,
59
- indent: bool,
67
+ cli_indent: bool,
68
+ cli_me: bool,
60
69
  obj: Resource[Any] | None,
61
70
  *args: Any,
62
71
  **kwargs: Any,
@@ -84,6 +93,13 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
84
93
 
85
94
  echo '{"id": "xxxx-yyyy", "userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"]}' | replace user
86
95
 
96
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
97
+ and :code:`--id` is not needed:
98
+
99
+ .. code-block:: bash
100
+
101
+ scim replace user --me --user-name "foo"
102
+
87
103
  """
88
104
  if obj == model():
89
105
  obj = None
@@ -95,8 +111,10 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
95
111
 
96
112
  replace_payload(
97
113
  ctx.obj["client"],
114
+ resource_type,
98
115
  payload,
99
- indent,
116
+ inherited(ctx, "cli_indent", cli_indent),
117
+ inherited(ctx, "cli_me", cli_me),
100
118
  )
101
119
 
102
120
  return escape_options_help(replace_command)
@@ -109,16 +127,12 @@ def replace_factory(model: type[Resource[Any]] | None) -> click.Command:
109
127
  invoke_without_command=True,
110
128
  )
111
129
  @click.pass_context
112
- @click.option(
113
- "--indent/--no-indent",
114
- is_flag=True,
115
- default=True,
116
- help="Indent JSON response payloads.",
117
- )
118
- def replace_cli(ctx: click.Context, indent: bool) -> None:
130
+ @indent_option("cli_indent")
131
+ @me_option("cli_me")
132
+ def replace_cli(ctx: click.Context, cli_indent: bool, cli_me: bool) -> None:
119
133
  """Perform a `SCIM PUT <https://www.rfc-editor.org/rfc/rfc7644#section-3.5.1>`_ request on the resources endpoint.
120
134
 
121
- There are subcommands for all the available models, with dynamic attributes.
135
+ There are subcommands for all the resource types of the server, with dynamic attributes.
122
136
  See the attributes for :code:`user` with:
123
137
 
124
138
  .. code-block:: bash
@@ -131,6 +145,9 @@ def replace_cli(ctx: click.Context, indent: bool) -> None:
131
145
 
132
146
  echo '{"userName": "bjensen@example.com", "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "id": "1234"}' | replace user
133
147
 
148
+ With :code:`--me`, the request is made on the :code:`/Me` endpoint,
149
+ and the id is not needed.
150
+
134
151
  """
135
152
  if ctx.invoked_subcommand is not None:
136
153
  return
@@ -140,8 +157,4 @@ def replace_cli(ctx: click.Context, indent: bool) -> None:
140
157
  click.echo(ctx.get_help())
141
158
  ctx.exit(1)
142
159
 
143
- replace_payload(
144
- ctx.obj["client"],
145
- payload,
146
- indent,
147
- )
160
+ replace_payload(ctx.obj["client"], None, payload, cli_indent, cli_me)
@@ -3,6 +3,7 @@ from scim2_models import SearchRequest
3
3
  from sphinx_click.rst_to_ansi_formatter import make_rst_to_ansi_formatter
4
4
 
5
5
  from scim2_cli.utils import exception_to_click_error
6
+ from scim2_cli.utils import find_target
6
7
 
7
8
  from .utils import DOC_URL
8
9
  from .utils import SCIM_EXCEPTIONS
@@ -11,6 +12,7 @@ from .utils import formatted_payload
11
12
 
12
13
  @click.command(cls=make_rst_to_ansi_formatter(DOC_URL), name="search")
13
14
  @click.pass_context
15
+ @click.argument("resource_type", required=False)
14
16
  @click.option(
15
17
  "--attribute",
16
18
  multiple=True,
@@ -26,6 +28,10 @@ from .utils import formatted_payload
26
28
  type=int,
27
29
  help="An integer indicating the 1-based index of the first query result.",
28
30
  )
31
+ @click.option(
32
+ "--cursor",
33
+ help="The cursor of the page to read, for cursor-based pagination (RFC 9865). Pass an empty value to read the first page.",
34
+ )
29
35
  @click.option(
30
36
  "--count",
31
37
  type=int,
@@ -50,16 +56,21 @@ from .utils import formatted_payload
50
56
  )
51
57
  def search_cli(
52
58
  ctx: click.Context,
59
+ resource_type: str | None,
53
60
  attribute: list[str],
54
61
  excluded_attribute: list[str],
55
62
  start_index: int | None,
63
+ cursor: str | None,
56
64
  count: int | None,
57
65
  filter: str | None,
58
66
  sort_by: str | None,
59
67
  sort_order: str | None,
60
68
  indent: bool,
61
69
  ) -> None:
62
- """Perform a `SCIM GET <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.1>`_ request on the :code:`/.search` endpoint.
70
+ """Perform a `SCIM POST search <https://www.rfc-editor.org/rfc/rfc7644#section-3.4.3>`_ request.
71
+
72
+ - If :code:`RESOURCE_TYPE` is :code:`user`, then the request will be made on the :code:`/Users/.search` endpoint.
73
+ - If :code:`RESOURCE_TYPE` is not set, then the request will be made on the :code:`/.search` endpoint.
63
74
 
64
75
  Data passed in JSON format to stdin is sent as request arguments and all the other query arguments are ignored:
65
76
 
@@ -68,6 +79,10 @@ def search_cli(
68
79
  echo '{"startIndex": 50, "count": 10}' | search user
69
80
 
70
81
  """
82
+ target = (
83
+ find_target(ctx.obj["resource_types"], resource_type) if resource_type else None
84
+ )
85
+
71
86
  if ctx.obj.get("stdin"):
72
87
  check_request_payload = False
73
88
  payload = ctx.obj.get("stdin")
@@ -76,9 +91,10 @@ def search_cli(
76
91
  check_request_payload = True
77
92
  payload = SearchRequest.model_validate(
78
93
  {
79
- "attributes": attribute,
80
- "excluded_attributes": excluded_attribute,
94
+ "attributes": attribute or None,
95
+ "excluded_attributes": excluded_attribute or None,
81
96
  "start_index": start_index,
97
+ "cursor": cursor,
82
98
  "count": count,
83
99
  "filter": filter,
84
100
  "sort_by": sort_by,
@@ -88,6 +104,7 @@ def search_cli(
88
104
 
89
105
  try:
90
106
  response = ctx.obj["client"].search(
107
+ target,
91
108
  search_request=payload,
92
109
  check_request_payload=check_request_payload,
93
110
  raise_scim_errors=False,
@@ -1,15 +1,21 @@
1
1
  import json
2
2
  import re
3
3
  from collections.abc import Callable
4
+ from collections.abc import Mapping
4
5
  from enum import StrEnum
5
6
  from typing import Any
7
+ from typing import TypeVar
8
+ from typing import cast
6
9
 
7
10
  import click
11
+ from click import ClickException
12
+ from click.core import ParameterSource
8
13
  from scim2_client import SCIMClientException
9
14
  from scim2_models import BaseModel
10
15
  from scim2_models import Context
11
16
  from scim2_models import Mutability
12
17
  from scim2_models import Resource
18
+ from scim2_models import ResourceType
13
19
  from scim2_models import SCIMException
14
20
  from sphinx_click.rst_to_ansi_formatter import RstToAnsiGroup
15
21
 
@@ -18,6 +24,34 @@ INDENTATION_SIZE = 4
18
24
 
19
25
  SCIM_EXCEPTIONS = (SCIMClientException, SCIMException)
20
26
 
27
+ T = TypeVar("T")
28
+ F = TypeVar("F", bound=Callable[..., Any])
29
+
30
+ COMMAND_OPTIONS = {"me", "indent", "no-indent", "help"}
31
+
32
+
33
+ def me_option(*param_decls: str) -> Callable[[F], F]:
34
+ """Add the --me option, with an optional parameter name."""
35
+ return click.option(
36
+ "--me",
37
+ *param_decls,
38
+ is_flag=True,
39
+ default=False,
40
+ help="Act on the resource of the authenticated client, under /Me (RFC 7644 §3.11).",
41
+ )
42
+
43
+
44
+ def indent_option(*param_decls: str) -> Callable[[F], F]:
45
+ """Add the --indent option, with an optional parameter name."""
46
+ return click.option(
47
+ "--indent/--no-indent",
48
+ *param_decls,
49
+ is_flag=True,
50
+ default=True,
51
+ help="Indent JSON response payloads.",
52
+ )
53
+
54
+
21
55
  CONTROL_CHARACTERS = re.compile(
22
56
  "[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]"
23
57
  )
@@ -66,6 +100,31 @@ def escape_options_help(command: click.Command) -> click.Command:
66
100
  return command
67
101
 
68
102
 
103
+ def command_name(resource_type: ResourceType) -> str:
104
+ """Name the commands acting on a resource type after its name, or else its id."""
105
+ return escape_control_characters(resource_type.name or resource_type.id).lower()
106
+
107
+
108
+ def find_target(targets: Mapping[str, T], name: str) -> T:
109
+ """Find what a resource type argument designates, ignoring the case."""
110
+ try:
111
+ return targets[name.lower()]
112
+ except KeyError as exc:
113
+ ok_values = ", ".join(targets)
114
+ raise ClickException(
115
+ f"Unknown resource type '{escape_control_characters(name)}'. "
116
+ f"Available values are: {ok_values}"
117
+ ) from exc
118
+
119
+
120
+ def inherited(ctx: click.Context, name: str, value: T) -> T:
121
+ """Read an option of a subcommand, or else the one passed to its parent command."""
122
+ parent = ctx.parent
123
+ if parent is None or ctx.get_parameter_source(name) is not ParameterSource.DEFAULT:
124
+ return value
125
+ return cast(T, parent.params[name])
126
+
127
+
69
128
  def formatted_payload(obj: Any, indent: bool) -> str:
70
129
  return json.dumps(obj, indent=INDENTATION_SIZE if indent else None)
71
130
 
@@ -82,14 +141,14 @@ def split_headers(headers: list[str]) -> dict[str, str]:
82
141
 
83
142
 
84
143
  class ModelCommand(RstToAnsiGroup):
85
- """CLI commands that takes a model subcommand."""
144
+ """CLI commands that take a subcommand for each resource type."""
86
145
 
87
146
  base_url = DOC_URL
88
147
 
89
148
  def __init__(
90
149
  self,
91
150
  *args: Any,
92
- factory: Callable[[type[Resource[Any]] | None], click.Command],
151
+ factory: Callable[[ResourceType, type[Resource[Any]]], click.Command],
93
152
  **kwargs: Any,
94
153
  ) -> None:
95
154
  super().__init__(*args, **kwargs)
@@ -98,12 +157,15 @@ class ModelCommand(RstToAnsiGroup):
98
157
  def list_commands(self, ctx: click.Context) -> list[str]:
99
158
  ctx.ensure_object(dict)
100
159
  base = super().list_commands(ctx)
101
- lazy = sorted(ctx.obj.get("resource_models", {}).keys())
160
+ lazy = sorted(ctx.obj.get("resource_types", {}).keys())
102
161
  return base + lazy
103
162
 
104
- def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
105
- model = ctx.obj["resource_models"].get(cmd_name)
106
- return self.factory(model)
163
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
164
+ resource_type = ctx.obj["resource_types"].get(cmd_name.lower())
165
+ if resource_type is None:
166
+ return None
167
+ model = ctx.obj["client"].provider.model_for(resource_type)
168
+ return self.factory(resource_type, model)
107
169
 
108
170
 
109
171
  def is_field_acceptable(
@@ -144,6 +206,16 @@ def unacceptable_fields(context: Context, model: type[BaseModel]) -> list[str]:
144
206
  return excluded
145
207
 
146
208
 
209
+ def renamed_fields(model: type[BaseModel], exclude: list[str]) -> dict[str, str]:
210
+ """Rename the options of the attributes that collide with the options of the command."""
211
+ return {
212
+ field_name: f"--{option}-attribute"
213
+ for field_name in model.model_fields
214
+ if field_name not in exclude
215
+ and (option := field_name.replace("_", "-")) in COMMAND_OPTIONS
216
+ }
217
+
218
+
147
219
  def exception_to_click_error(exception: Exception) -> click.ClickException:
148
220
  message = str(exception)
149
221
  if hasattr(exception, "__notes__"):
File without changes
File without changes
File without changes
File without changes