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

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