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

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