scim2-client 0.1.4__py3-none-any.whl → 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scim2_client/__init__.py +14 -2
- scim2_client/client.py +313 -102
- scim2_client/errors.py +111 -29
- {scim2_client-0.1.4.dist-info → scim2_client-0.1.6.dist-info}/METADATA +17 -2
- scim2_client-0.1.6.dist-info/RECORD +6 -0
- scim2_client-0.1.4.dist-info/RECORD +0 -6
- {scim2_client-0.1.4.dist-info → scim2_client-0.1.6.dist-info}/WHEEL +0 -0
scim2_client/__init__.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
from .client import SCIMClient
|
|
2
|
+
from .errors import RequestNetworkError
|
|
3
|
+
from .errors import RequestPayloadValidationError
|
|
4
|
+
from .errors import ResponsePayloadValidationError
|
|
2
5
|
from .errors import SCIMClientError
|
|
6
|
+
from .errors import SCIMRequestError
|
|
7
|
+
from .errors import SCIMResponseError
|
|
8
|
+
from .errors import SCIMResponseErrorObject
|
|
3
9
|
from .errors import UnexpectedContentFormat
|
|
4
10
|
from .errors import UnexpectedContentType
|
|
5
11
|
from .errors import UnexpectedStatusCode
|
|
@@ -7,7 +13,13 @@ from .errors import UnexpectedStatusCode
|
|
|
7
13
|
__all__ = [
|
|
8
14
|
"SCIMClient",
|
|
9
15
|
"SCIMClientError",
|
|
10
|
-
"
|
|
11
|
-
"
|
|
16
|
+
"SCIMRequestError",
|
|
17
|
+
"SCIMResponseError",
|
|
18
|
+
"SCIMResponseErrorObject",
|
|
12
19
|
"UnexpectedContentFormat",
|
|
20
|
+
"UnexpectedContentType",
|
|
21
|
+
"UnexpectedStatusCode",
|
|
22
|
+
"RequestPayloadValidationError",
|
|
23
|
+
"RequestNetworkError",
|
|
24
|
+
"ResponsePayloadValidationError",
|
|
13
25
|
]
|
scim2_client/client.py
CHANGED
|
@@ -8,6 +8,7 @@ from typing import Type
|
|
|
8
8
|
from typing import Union
|
|
9
9
|
|
|
10
10
|
from httpx import Client
|
|
11
|
+
from httpx import RequestError
|
|
11
12
|
from httpx import Response
|
|
12
13
|
from pydantic import ValidationError
|
|
13
14
|
from scim2_models import AnyResource
|
|
@@ -16,9 +17,18 @@ from scim2_models import Error
|
|
|
16
17
|
from scim2_models import ListResponse
|
|
17
18
|
from scim2_models import PatchOp
|
|
18
19
|
from scim2_models import Resource
|
|
20
|
+
from scim2_models import ResourceType
|
|
21
|
+
from scim2_models import Schema
|
|
19
22
|
from scim2_models import SearchRequest
|
|
23
|
+
from scim2_models import ServiceProviderConfig
|
|
20
24
|
|
|
25
|
+
from .errors import RequestNetworkError
|
|
26
|
+
from .errors import RequestPayloadValidationError
|
|
27
|
+
from .errors import ResponsePayloadValidationError
|
|
21
28
|
from .errors import SCIMClientError
|
|
29
|
+
from .errors import SCIMRequestError
|
|
30
|
+
from .errors import SCIMResponseError
|
|
31
|
+
from .errors import SCIMResponseErrorObject
|
|
22
32
|
from .errors import UnexpectedContentFormat
|
|
23
33
|
from .errors import UnexpectedContentType
|
|
24
34
|
from .errors import UnexpectedStatusCode
|
|
@@ -30,7 +40,16 @@ BASE_HEADERS = {
|
|
|
30
40
|
|
|
31
41
|
|
|
32
42
|
class SCIMClient:
|
|
33
|
-
"""An object that perform SCIM requests and validate responses.
|
|
43
|
+
"""An object that perform SCIM requests and validate responses.
|
|
44
|
+
|
|
45
|
+
:param client: A :class:`httpx.Client` instance that will be used to send requests.
|
|
46
|
+
:param resource_types: A tuple of :class:`~scim2_models.Resource` types expected to be handled by the SCIMClient.
|
|
47
|
+
If a request payload describe a resource that is not in this list, an exception will be raised.
|
|
48
|
+
|
|
49
|
+
.. note::
|
|
50
|
+
|
|
51
|
+
:class:`~scim2_models.ResourceType`, :class:`~scim2_models.Schema` and :class:`scim2_models.ServiceProviderConfig` are pre-loaded by default.
|
|
52
|
+
"""
|
|
34
53
|
|
|
35
54
|
CREATION_RESPONSE_STATUS_CODES: List[int] = [
|
|
36
55
|
201,
|
|
@@ -99,13 +118,22 @@ class SCIMClient:
|
|
|
99
118
|
|
|
100
119
|
def __init__(self, client: Client, resource_types: Optional[Tuple[Type]] = None):
|
|
101
120
|
self.client = client
|
|
102
|
-
self.resource_types =
|
|
121
|
+
self.resource_types = tuple(
|
|
122
|
+
set(resource_types or []) | {ResourceType, Schema, ServiceProviderConfig}
|
|
123
|
+
)
|
|
103
124
|
|
|
104
125
|
def check_resource_type(self, resource_type):
|
|
105
126
|
if resource_type not in self.resource_types:
|
|
106
|
-
raise
|
|
127
|
+
raise SCIMRequestError(f"Unknown resource type: '{resource_type}'")
|
|
107
128
|
|
|
108
129
|
def resource_endpoint(self, resource_type: Type):
|
|
130
|
+
if resource_type is None:
|
|
131
|
+
return "/"
|
|
132
|
+
|
|
133
|
+
# This one takes no final 's'
|
|
134
|
+
if resource_type is ServiceProviderConfig:
|
|
135
|
+
return "/ServiceProviderConfig"
|
|
136
|
+
|
|
109
137
|
try:
|
|
110
138
|
first_bracket_index = resource_type.__name__.index("[")
|
|
111
139
|
root_name = resource_type.__name__[:first_bracket_index]
|
|
@@ -117,11 +145,13 @@ class SCIMClient:
|
|
|
117
145
|
self,
|
|
118
146
|
response: Response,
|
|
119
147
|
expected_status_codes: List[int],
|
|
120
|
-
|
|
148
|
+
expected_types: Optional[Type] = None,
|
|
149
|
+
check_response_payload: bool = True,
|
|
150
|
+
raise_scim_errors: bool = False,
|
|
121
151
|
scim_ctx: Optional[Context] = None,
|
|
122
152
|
):
|
|
123
153
|
if expected_status_codes and response.status_code not in expected_status_codes:
|
|
124
|
-
raise UnexpectedStatusCode(response)
|
|
154
|
+
raise UnexpectedStatusCode(source=response)
|
|
125
155
|
|
|
126
156
|
# Interoperability considerations: The "application/scim+json" media
|
|
127
157
|
# type is intended to identify JSON structure data that conforms to
|
|
@@ -131,7 +161,7 @@ class SCIMClient:
|
|
|
131
161
|
|
|
132
162
|
expected_response_content_types = ("application/scim+json", "application/json")
|
|
133
163
|
if response.headers.get("content-type") not in expected_response_content_types:
|
|
134
|
-
raise UnexpectedContentType(response)
|
|
164
|
+
raise UnexpectedContentType(source=response)
|
|
135
165
|
|
|
136
166
|
# In addition to returning an HTTP response code, implementers MUST return
|
|
137
167
|
# the errors in the body of the response in a JSON format
|
|
@@ -145,21 +175,45 @@ class SCIMClient:
|
|
|
145
175
|
try:
|
|
146
176
|
response_payload = response.json()
|
|
147
177
|
except json.decoder.JSONDecodeError as exc:
|
|
148
|
-
raise UnexpectedContentFormat(response) from exc
|
|
178
|
+
raise UnexpectedContentFormat(source=response) from exc
|
|
179
|
+
|
|
180
|
+
if not check_response_payload:
|
|
181
|
+
return response_payload
|
|
149
182
|
|
|
150
183
|
try:
|
|
151
|
-
|
|
184
|
+
error = Error.model_validate(response_payload)
|
|
185
|
+
if raise_scim_errors:
|
|
186
|
+
raise SCIMResponseErrorObject(source=error)
|
|
187
|
+
return error
|
|
152
188
|
except ValidationError:
|
|
153
189
|
pass
|
|
154
190
|
|
|
155
|
-
if
|
|
191
|
+
if not expected_types:
|
|
192
|
+
return response_payload
|
|
193
|
+
|
|
194
|
+
actual_type = Resource.get_by_payload(
|
|
195
|
+
expected_types, response_payload, with_extensions=False
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not actual_type:
|
|
199
|
+
expected = ", ".join([type.__name__ for type in expected_types])
|
|
156
200
|
try:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
201
|
+
schema = ", ".join(response_payload["schemas"])
|
|
202
|
+
message = f"Expected type {expected} but got unknow resource with schemas: {schema}"
|
|
203
|
+
except KeyError:
|
|
204
|
+
message = (
|
|
205
|
+
f"Expected type {expected} but got undefined object with no schema"
|
|
206
|
+
)
|
|
161
207
|
|
|
162
|
-
|
|
208
|
+
raise SCIMResponseError(message, source=response)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
return actual_type.model_validate(response_payload, scim_ctx=scim_ctx)
|
|
212
|
+
except ValidationError as exc:
|
|
213
|
+
scim_exc = ResponsePayloadValidationError(source=response)
|
|
214
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
215
|
+
scim_exc.add_note(str(exc))
|
|
216
|
+
raise scim_exc from exc
|
|
163
217
|
|
|
164
218
|
def create(
|
|
165
219
|
self,
|
|
@@ -167,6 +221,7 @@ class SCIMClient:
|
|
|
167
221
|
check_request_payload: bool = True,
|
|
168
222
|
check_response_payload: bool = True,
|
|
169
223
|
check_status_code: bool = True,
|
|
224
|
+
raise_scim_errors: bool = False,
|
|
170
225
|
**kwargs,
|
|
171
226
|
) -> Union[AnyResource, Error, Dict]:
|
|
172
227
|
"""Perform a POST request to create, as defined in :rfc:`RFC7644 §3.3
|
|
@@ -179,12 +234,32 @@ class SCIMClient:
|
|
|
179
234
|
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
180
235
|
If set, the raw payload will be returned.
|
|
181
236
|
:param check_status_code: Whether to validate that the response status code is valid.
|
|
237
|
+
:param raise_scim_errors: If :data:`True` and the server returned an
|
|
238
|
+
:class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
|
|
239
|
+
exception will be raised. If :data:`False` the error object is returned.
|
|
182
240
|
:param kwargs: Additional parameters passed to the underlying HTTP request
|
|
183
241
|
library.
|
|
184
242
|
|
|
185
243
|
:return:
|
|
186
244
|
- An :class:`~scim2_models.Error` object in case of error.
|
|
187
|
-
- The created object as returned by the server in case of success
|
|
245
|
+
- The created object as returned by the server in case of success and :code:`check_response_payload` is :data:`True`.
|
|
246
|
+
- The created object payload as returned by the server in case of success and :code:`check_response_payload` is :data:`False`.
|
|
247
|
+
|
|
248
|
+
.. code-block:: python
|
|
249
|
+
:caption: Creation of a `User` resource
|
|
250
|
+
|
|
251
|
+
from scim2_models import User
|
|
252
|
+
|
|
253
|
+
request = User(user_name="bjensen@example.com")
|
|
254
|
+
response = scim.create(request)
|
|
255
|
+
# 'response' may be a User or an Error object
|
|
256
|
+
|
|
257
|
+
.. tip::
|
|
258
|
+
|
|
259
|
+
Check the :attr:`~scim2_models.Context.RESOURCE_CREATION_REQUEST`
|
|
260
|
+
and :attr:`~scim2_models.Context.RESOURCE_CREATION_RESPONSE` contexts to understand
|
|
261
|
+
which value will excluded from the request payload, and which values are expected in
|
|
262
|
+
the response payload.
|
|
188
263
|
"""
|
|
189
264
|
|
|
190
265
|
if not check_request_payload:
|
|
@@ -198,35 +273,50 @@ class SCIMClient:
|
|
|
198
273
|
else:
|
|
199
274
|
resource_type = Resource.get_by_payload(self.resource_types, resource)
|
|
200
275
|
if not resource_type:
|
|
201
|
-
raise
|
|
202
|
-
|
|
276
|
+
raise SCIMRequestError(
|
|
277
|
+
"Cannot guess resource type from the payload"
|
|
203
278
|
)
|
|
204
279
|
|
|
205
|
-
|
|
280
|
+
try:
|
|
281
|
+
resource = resource_type.model_validate(resource)
|
|
282
|
+
except ValidationError as exc:
|
|
283
|
+
scim_exc = RequestPayloadValidationError(source=resource)
|
|
284
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
285
|
+
scim_exc.add_note(str(exc))
|
|
286
|
+
raise scim_exc from exc
|
|
206
287
|
|
|
207
288
|
self.check_resource_type(resource_type)
|
|
208
289
|
url = kwargs.pop("url", self.resource_endpoint(resource_type))
|
|
209
290
|
payload = resource.model_dump(scim_ctx=Context.RESOURCE_CREATION_REQUEST)
|
|
210
291
|
|
|
211
|
-
|
|
292
|
+
try:
|
|
293
|
+
response = self.client.post(url, json=payload, **kwargs)
|
|
294
|
+
except RequestError as exc:
|
|
295
|
+
scim_exc = RequestNetworkError(source=payload)
|
|
296
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
297
|
+
scim_exc.add_note(str(exc))
|
|
298
|
+
raise scim_exc from exc
|
|
212
299
|
|
|
213
300
|
return self.check_response(
|
|
214
|
-
response,
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
else None,
|
|
301
|
+
response=response,
|
|
302
|
+
expected_status_codes=(
|
|
303
|
+
self.CREATION_RESPONSE_STATUS_CODES if check_status_code else None
|
|
304
|
+
),
|
|
305
|
+
expected_types=([resource.__class__] if check_request_payload else None),
|
|
306
|
+
check_response_payload=check_response_payload,
|
|
307
|
+
raise_scim_errors=raise_scim_errors,
|
|
219
308
|
scim_ctx=Context.RESOURCE_CREATION_RESPONSE,
|
|
220
309
|
)
|
|
221
310
|
|
|
222
311
|
def query(
|
|
223
312
|
self,
|
|
224
|
-
resource_type: Type,
|
|
313
|
+
resource_type: Optional[Type] = None,
|
|
225
314
|
id: Optional[str] = None,
|
|
226
315
|
search_request: Optional[Union[SearchRequest, Dict]] = None,
|
|
227
316
|
check_request_payload: bool = True,
|
|
228
317
|
check_response_payload: bool = True,
|
|
229
318
|
check_status_code: bool = True,
|
|
319
|
+
raise_scim_errors: bool = False,
|
|
230
320
|
**kwargs,
|
|
231
321
|
) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
|
|
232
322
|
"""Perform a GET request to read resources, as defined in :rfc:`RFC7644
|
|
@@ -243,72 +333,58 @@ class SCIMClient:
|
|
|
243
333
|
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
244
334
|
If set, the raw payload will be returned.
|
|
245
335
|
:param check_status_code: Whether to validate that the response status code is valid.
|
|
336
|
+
:param raise_scim_errors: If :data:`True` and the server returned an
|
|
337
|
+
:class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
|
|
338
|
+
exception will be raised. If :data:`False` the error object is returned.
|
|
246
339
|
:param kwargs: Additional parameters passed to the underlying HTTP request library.
|
|
247
340
|
|
|
248
341
|
:return:
|
|
249
342
|
- A :class:`~scim2_models.Error` object in case of error.
|
|
250
343
|
- A `resource_type` object in case of success if `id` is not :data:`None`
|
|
251
344
|
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success if `id` is :data:`None`
|
|
252
|
-
"""
|
|
253
345
|
|
|
254
|
-
|
|
255
|
-
if not check_request_payload:
|
|
256
|
-
payload = search_request
|
|
346
|
+
.. note::
|
|
257
347
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
search_request.model_dump(
|
|
261
|
-
exclude_unset=True,
|
|
262
|
-
scim_ctx=Context.RESOURCE_QUERY_REQUEST,
|
|
263
|
-
)
|
|
264
|
-
if search_request
|
|
265
|
-
else None
|
|
266
|
-
)
|
|
348
|
+
Querying a :class:`~scim2_models.ServiceProviderConfig` will return a
|
|
349
|
+
single object, and not a :class:`~scim2_models.ListResponse`.
|
|
267
350
|
|
|
268
|
-
|
|
269
|
-
expected_type = ListResponse[resource_type]
|
|
270
|
-
url = self.resource_endpoint(resource_type)
|
|
351
|
+
:usage:
|
|
271
352
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
353
|
+
.. code-block:: python
|
|
354
|
+
:caption: Query of a `User` resource knowing its id
|
|
275
355
|
|
|
276
|
-
|
|
277
|
-
return self.check_response(
|
|
278
|
-
response,
|
|
279
|
-
self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None,
|
|
280
|
-
expected_type if check_response_payload else None,
|
|
281
|
-
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
282
|
-
)
|
|
356
|
+
from scim2_models import User
|
|
283
357
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
search_request: Optional[SearchRequest] = None,
|
|
287
|
-
check_request_payload: bool = True,
|
|
288
|
-
check_response_payload: bool = True,
|
|
289
|
-
check_status_code: bool = True,
|
|
290
|
-
**kwargs,
|
|
291
|
-
) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
|
|
292
|
-
"""Perform a GET request to read all available resources, as defined in
|
|
293
|
-
:rfc:`RFC7644 §3.4.2.1 <7644#section-3.4.2.1>`.
|
|
358
|
+
response = scim.query(User, "my-user-id)
|
|
359
|
+
# 'response' may be a User or an Error object
|
|
294
360
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
:code:`search_request` is expected to be a dict that will be passed as-is in the request.
|
|
298
|
-
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
299
|
-
If set, the raw payload will be returned.
|
|
300
|
-
:param check_status_code: Whether to validate that the response status code is valid.
|
|
301
|
-
:param kwargs: Additional parameters passed to the underlying
|
|
302
|
-
HTTP request library.
|
|
361
|
+
.. code-block:: python
|
|
362
|
+
:caption: Query of all the `User` resources filtering the ones with `userName` starts with `john`
|
|
303
363
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
364
|
+
from scim2_models import User, SearchRequest
|
|
365
|
+
|
|
366
|
+
req = SearchRequest(filter='userName sw "john"')
|
|
367
|
+
response = scim.query(User, search_request=search_request)
|
|
368
|
+
# 'response' may be a ListResponse[User] or an Error object
|
|
369
|
+
|
|
370
|
+
.. code-block:: python
|
|
371
|
+
:caption: Query of all the available resources
|
|
372
|
+
|
|
373
|
+
from scim2_models import User, SearchRequest
|
|
374
|
+
|
|
375
|
+
response = scim.query()
|
|
376
|
+
# 'response' may be a ListResponse[Union[User, Group, ...]] or an Error object
|
|
377
|
+
|
|
378
|
+
.. tip::
|
|
379
|
+
|
|
380
|
+
Check the :attr:`~scim2_models.Context.RESOURCE_QUERY_REQUEST`
|
|
381
|
+
and :attr:`~scim2_models.Context.RESOURCE_QUERY_RESPONSE` contexts to understand
|
|
382
|
+
which value will excluded from the request payload, and which values are expected in
|
|
383
|
+
the response payload.
|
|
307
384
|
"""
|
|
308
385
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
# https://datatracker.ietf.org/doc/html/rfc7644.html#section-3.4.2.1
|
|
386
|
+
if resource_type and check_request_payload:
|
|
387
|
+
self.check_resource_type(resource_type)
|
|
312
388
|
|
|
313
389
|
if not check_request_payload:
|
|
314
390
|
payload = search_request
|
|
@@ -316,20 +392,49 @@ class SCIMClient:
|
|
|
316
392
|
else:
|
|
317
393
|
payload = (
|
|
318
394
|
search_request.model_dump(
|
|
319
|
-
exclude_unset=True,
|
|
395
|
+
exclude_unset=True,
|
|
396
|
+
scim_ctx=Context.RESOURCE_QUERY_REQUEST,
|
|
320
397
|
)
|
|
321
398
|
if search_request
|
|
322
399
|
else None
|
|
323
400
|
)
|
|
324
401
|
|
|
325
|
-
|
|
402
|
+
url = kwargs.pop("url", self.resource_endpoint(resource_type))
|
|
403
|
+
|
|
404
|
+
if resource_type is None:
|
|
405
|
+
expected_types = [
|
|
406
|
+
*self.resource_types,
|
|
407
|
+
ListResponse[Union[self.resource_types]],
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
elif resource_type == ServiceProviderConfig:
|
|
411
|
+
expected_types = [resource_type]
|
|
412
|
+
if id:
|
|
413
|
+
raise SCIMClientError("ServiceProviderConfig cannot have an id")
|
|
414
|
+
|
|
415
|
+
elif id:
|
|
416
|
+
expected_types = [resource_type]
|
|
417
|
+
url = f"{url}/{id}"
|
|
418
|
+
|
|
419
|
+
else:
|
|
420
|
+
expected_types = [ListResponse[resource_type]]
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
response = self.client.get(url, params=payload, **kwargs)
|
|
424
|
+
except RequestError as exc:
|
|
425
|
+
scim_exc = RequestNetworkError(source=payload)
|
|
426
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
427
|
+
scim_exc.add_note(str(exc))
|
|
428
|
+
raise scim_exc from exc
|
|
326
429
|
|
|
327
430
|
return self.check_response(
|
|
328
|
-
response,
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
431
|
+
response=response,
|
|
432
|
+
expected_status_codes=(
|
|
433
|
+
self.QUERY_RESPONSE_STATUS_CODES if check_status_code else None
|
|
434
|
+
),
|
|
435
|
+
expected_types=expected_types,
|
|
436
|
+
check_response_payload=check_response_payload,
|
|
437
|
+
raise_scim_errors=raise_scim_errors,
|
|
333
438
|
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
334
439
|
)
|
|
335
440
|
|
|
@@ -339,6 +444,7 @@ class SCIMClient:
|
|
|
339
444
|
check_request_payload: bool = True,
|
|
340
445
|
check_response_payload: bool = True,
|
|
341
446
|
check_status_code: bool = True,
|
|
447
|
+
raise_scim_errors: bool = False,
|
|
342
448
|
**kwargs,
|
|
343
449
|
) -> Union[AnyResource, ListResponse[AnyResource], Error, Dict]:
|
|
344
450
|
"""Perform a POST search request to read all available resources, as
|
|
@@ -352,12 +458,33 @@ class SCIMClient:
|
|
|
352
458
|
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
353
459
|
If set, the raw payload will be returned.
|
|
354
460
|
:param check_status_code: Whether to validate that the response status code is valid.
|
|
461
|
+
:param raise_scim_errors: If :data:`True` and the server returned an
|
|
462
|
+
:class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
|
|
463
|
+
exception will be raised. If :data:`False` the error object is returned.
|
|
355
464
|
:param kwargs: Additional parameters passed to the underlying
|
|
356
465
|
HTTP request library.
|
|
357
466
|
|
|
358
467
|
:return:
|
|
359
468
|
- A :class:`~scim2_models.Error` object in case of error.
|
|
360
469
|
- A :class:`~scim2_models.ListResponse[resource_type]` object in case of success.
|
|
470
|
+
|
|
471
|
+
:usage:
|
|
472
|
+
|
|
473
|
+
.. code-block:: python
|
|
474
|
+
:caption: Searching for all the resources filtering the ones with `id` contains with `admin`
|
|
475
|
+
|
|
476
|
+
from scim2_models import User, SearchRequest
|
|
477
|
+
|
|
478
|
+
req = SearchRequest(filter='id co "john"')
|
|
479
|
+
response = scim.search(search_request=search_request)
|
|
480
|
+
# 'response' may be a ListResponse[User] or an Error object
|
|
481
|
+
|
|
482
|
+
.. tip::
|
|
483
|
+
|
|
484
|
+
Check the :attr:`~scim2_models.Context.SEARCH_REQUEST`
|
|
485
|
+
and :attr:`~scim2_models.Context.SEARCH_RESPONSE` contexts to understand
|
|
486
|
+
which value will excluded from the request payload, and which values are expected in
|
|
487
|
+
the response payload.
|
|
361
488
|
"""
|
|
362
489
|
|
|
363
490
|
if not check_request_payload:
|
|
@@ -372,38 +499,84 @@ class SCIMClient:
|
|
|
372
499
|
else None
|
|
373
500
|
)
|
|
374
501
|
|
|
375
|
-
|
|
502
|
+
url = kwargs.pop("url", "/.search")
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
response = self.client.post(url, json=payload)
|
|
506
|
+
except RequestError as exc:
|
|
507
|
+
scim_exc = RequestNetworkError(source=payload)
|
|
508
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
509
|
+
scim_exc.add_note(str(exc))
|
|
510
|
+
raise scim_exc from exc
|
|
376
511
|
|
|
377
512
|
return self.check_response(
|
|
378
|
-
response,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
513
|
+
response=response,
|
|
514
|
+
expected_status_codes=(
|
|
515
|
+
self.SEARCH_RESPONSE_STATUS_CODES if check_status_code else None
|
|
516
|
+
),
|
|
517
|
+
expected_types=[ListResponse[Union[self.resource_types]]],
|
|
518
|
+
check_response_payload=check_response_payload,
|
|
519
|
+
raise_scim_errors=raise_scim_errors,
|
|
383
520
|
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
|
|
384
521
|
)
|
|
385
522
|
|
|
386
523
|
def delete(
|
|
387
|
-
self,
|
|
524
|
+
self,
|
|
525
|
+
resource_type: Type,
|
|
526
|
+
id: str,
|
|
527
|
+
check_response_payload: bool = True,
|
|
528
|
+
check_status_code: bool = True,
|
|
529
|
+
raise_scim_errors: bool = False,
|
|
530
|
+
**kwargs,
|
|
388
531
|
) -> Optional[Union[Error, Dict]]:
|
|
389
532
|
"""Perform a DELETE request to create, as defined in :rfc:`RFC7644 §3.6
|
|
390
533
|
<7644#section-3.6>`.
|
|
391
534
|
|
|
535
|
+
:param resource_type: The type of the resource to delete.
|
|
536
|
+
:param id: The type id the resource to delete.
|
|
537
|
+
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
538
|
+
If set, the raw payload will be returned.
|
|
392
539
|
:param check_status_code: Whether to validate that the response status code is valid.
|
|
540
|
+
:param raise_scim_errors: If :data:`True` and the server returned an
|
|
541
|
+
:class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
|
|
542
|
+
exception will be raised. If :data:`False` the error object is returned.
|
|
393
543
|
:param kwargs: Additional parameters passed to the underlying
|
|
394
544
|
HTTP request library.
|
|
395
545
|
|
|
396
546
|
:return:
|
|
397
547
|
- A :class:`~scim2_models.Error` object in case of error.
|
|
398
548
|
- :data:`None` in case of success.
|
|
549
|
+
|
|
550
|
+
:usage:
|
|
551
|
+
|
|
552
|
+
.. code-block:: python
|
|
553
|
+
:caption: Deleting an `User` which `id` is `foobar`
|
|
554
|
+
|
|
555
|
+
from scim2_models import User, SearchRequest
|
|
556
|
+
|
|
557
|
+
response = scim.delete(User, "foobar")
|
|
558
|
+
# 'response' may be None, or an Error object
|
|
399
559
|
"""
|
|
400
560
|
|
|
401
561
|
self.check_resource_type(resource_type)
|
|
402
|
-
|
|
403
|
-
|
|
562
|
+
delete_url = self.resource_endpoint(resource_type) + f"/{id}"
|
|
563
|
+
url = kwargs.pop("url", delete_url)
|
|
564
|
+
|
|
565
|
+
try:
|
|
566
|
+
response = self.client.delete(url, **kwargs)
|
|
567
|
+
except RequestError as exc:
|
|
568
|
+
scim_exc = RequestNetworkError()
|
|
569
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
570
|
+
scim_exc.add_note(str(exc))
|
|
571
|
+
raise scim_exc from exc
|
|
404
572
|
|
|
405
573
|
return self.check_response(
|
|
406
|
-
response,
|
|
574
|
+
response=response,
|
|
575
|
+
expected_status_codes=(
|
|
576
|
+
self.DELETION_RESPONSE_STATUS_CODES if check_status_code else None
|
|
577
|
+
),
|
|
578
|
+
check_response_payload=check_response_payload,
|
|
579
|
+
raise_scim_errors=raise_scim_errors,
|
|
407
580
|
)
|
|
408
581
|
|
|
409
582
|
def replace(
|
|
@@ -412,6 +585,7 @@ class SCIMClient:
|
|
|
412
585
|
check_request_payload: bool = True,
|
|
413
586
|
check_response_payload: bool = True,
|
|
414
587
|
check_status_code: bool = True,
|
|
588
|
+
raise_scim_errors: bool = False,
|
|
415
589
|
**kwargs,
|
|
416
590
|
) -> Union[AnyResource, Error, Dict]:
|
|
417
591
|
"""Perform a PUT request to replace a resource, as defined in
|
|
@@ -424,12 +598,34 @@ class SCIMClient:
|
|
|
424
598
|
:param check_response_payload: Whether to validate that the response payload is valid.
|
|
425
599
|
If set, the raw payload will be returned.
|
|
426
600
|
:param check_status_code: Whether to validate that the response status code is valid.
|
|
601
|
+
:param raise_scim_errors: If :data:`True` and the server returned an
|
|
602
|
+
:class:`~scim2_models.Error` object, a :class:`~scim2_client.SCIMResponseErrorObject`
|
|
603
|
+
exception will be raised. If :data:`False` the error object is returned.
|
|
427
604
|
:param kwargs: Additional parameters passed to the underlying
|
|
428
605
|
HTTP request library.
|
|
429
606
|
|
|
430
607
|
:return:
|
|
431
608
|
- An :class:`~scim2_models.Error` object in case of error.
|
|
432
609
|
- The updated object as returned by the server in case of success.
|
|
610
|
+
|
|
611
|
+
:usage:
|
|
612
|
+
|
|
613
|
+
.. code-block:: python
|
|
614
|
+
:caption: Replacement of a `User` resource
|
|
615
|
+
|
|
616
|
+
from scim2_models import User
|
|
617
|
+
|
|
618
|
+
user = scim.query(User, "my-used-id")
|
|
619
|
+
user.display_name = "Fancy New Name"
|
|
620
|
+
response = scim.update(user)
|
|
621
|
+
# 'response' may be a User or an Error object
|
|
622
|
+
|
|
623
|
+
.. tip::
|
|
624
|
+
|
|
625
|
+
Check the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`
|
|
626
|
+
and :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_RESPONSE` contexts to understand
|
|
627
|
+
which value will excluded from the request payload, and which values are expected in
|
|
628
|
+
the response payload.
|
|
433
629
|
"""
|
|
434
630
|
|
|
435
631
|
if not check_request_payload:
|
|
@@ -443,30 +639,45 @@ class SCIMClient:
|
|
|
443
639
|
else:
|
|
444
640
|
resource_type = Resource.get_by_payload(self.resource_types, resource)
|
|
445
641
|
if not resource_type:
|
|
446
|
-
raise
|
|
447
|
-
|
|
642
|
+
raise SCIMRequestError(
|
|
643
|
+
"Cannot guess resource type from the payload",
|
|
644
|
+
source=resource,
|
|
448
645
|
)
|
|
449
646
|
|
|
450
|
-
|
|
647
|
+
try:
|
|
648
|
+
resource = resource_type.model_validate(resource)
|
|
649
|
+
except ValidationError as exc:
|
|
650
|
+
scim_exc = RequestPayloadValidationError(source=resource)
|
|
651
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
652
|
+
scim_exc.add_note(str(exc))
|
|
653
|
+
raise scim_exc from exc
|
|
451
654
|
|
|
452
655
|
self.check_resource_type(resource_type)
|
|
453
656
|
|
|
454
657
|
if not resource.id:
|
|
455
|
-
raise
|
|
658
|
+
raise SCIMRequestError("Resource must have an id", source=resource)
|
|
456
659
|
|
|
457
660
|
payload = resource.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
458
661
|
url = kwargs.pop(
|
|
459
662
|
"url", self.resource_endpoint(resource.__class__) + f"/{resource.id}"
|
|
460
663
|
)
|
|
461
664
|
|
|
462
|
-
|
|
665
|
+
try:
|
|
666
|
+
response = self.client.put(url, json=payload, **kwargs)
|
|
667
|
+
except RequestError as exc:
|
|
668
|
+
scim_exc = RequestNetworkError(source=payload)
|
|
669
|
+
if hasattr(scim_exc, "add_note"): # pragma: no cover
|
|
670
|
+
scim_exc.add_note(str(exc))
|
|
671
|
+
raise scim_exc from exc
|
|
463
672
|
|
|
464
673
|
return self.check_response(
|
|
465
|
-
response,
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
else None,
|
|
674
|
+
response=response,
|
|
675
|
+
expected_status_codes=(
|
|
676
|
+
self.REPLACEMENT_RESPONSE_STATUS_CODES if check_status_code else None
|
|
677
|
+
),
|
|
678
|
+
expected_types=([resource.__class__] if check_request_payload else None),
|
|
679
|
+
check_response_payload=check_response_payload,
|
|
680
|
+
raise_scim_errors=raise_scim_errors,
|
|
470
681
|
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE,
|
|
471
682
|
)
|
|
472
683
|
|
scim2_client/errors.py
CHANGED
|
@@ -1,43 +1,125 @@
|
|
|
1
|
-
from
|
|
1
|
+
from typing import Any
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
class SCIMClientError(Exception):
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
"""Base exception for scim2-client.
|
|
6
|
+
|
|
7
|
+
:param message: The exception reason.
|
|
8
|
+
:param source: The request payload or the response object that have
|
|
9
|
+
caused the exception.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, source: Any = None, *args, **kwargs):
|
|
13
|
+
self.message = message
|
|
14
|
+
self.source = source
|
|
7
15
|
super().__init__(*args, **kwargs)
|
|
8
16
|
|
|
17
|
+
def __str__(self):
|
|
18
|
+
return self.message or "UNKNOWN"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SCIMRequestError(SCIMClientError):
|
|
22
|
+
"""Base exception for errors happening during request payload building."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RequestNetworkError(SCIMRequestError):
|
|
26
|
+
"""Error raised when a network error happened during request.
|
|
27
|
+
|
|
28
|
+
This error is raised when a :class:`httpx.RequestError` has been catched while performing a request.
|
|
29
|
+
The original :class:`~httpx.RequestError` is available with :attr:`~BaseException.__cause__`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, *args, **kwargs):
|
|
33
|
+
message = kwargs.pop("message", "Network error happened during request")
|
|
34
|
+
super().__init__(message, *args, **kwargs)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RequestPayloadValidationError(SCIMRequestError):
|
|
38
|
+
"""Error raised when an invalid request payload has been passed to
|
|
39
|
+
SCIMClient.
|
|
40
|
+
|
|
41
|
+
This error is raised when a :class:`pydantic.ValidationError` has been catched
|
|
42
|
+
while validating the client request payload.
|
|
43
|
+
The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`.
|
|
44
|
+
|
|
45
|
+
.. code-block:: python
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
scim.create({"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "active": "not-a-bool"})
|
|
49
|
+
except RequestPayloadValidationError as exc:
|
|
50
|
+
print("Original validation error cause", exc.__cause__)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *args, **kwargs):
|
|
54
|
+
message = kwargs.pop("message", "Server response payload validation error")
|
|
55
|
+
super().__init__(message, *args, **kwargs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SCIMResponseError(SCIMClientError):
|
|
59
|
+
"""Base exception for errors happening during response payload
|
|
60
|
+
validation."""
|
|
9
61
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
62
|
+
|
|
63
|
+
class SCIMResponseErrorObject(SCIMResponseError):
|
|
64
|
+
"""The server response returned a :class:`scim2_models.Error` object.
|
|
65
|
+
|
|
66
|
+
Those errors are only raised when the :code:`raise_scim_errors` parameter is :data:`True`.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self, *args, **kwargs):
|
|
70
|
+
message = kwargs.pop(
|
|
71
|
+
"message",
|
|
72
|
+
f"The server returned a SCIM Error object: {kwargs['source'].detail}",
|
|
73
|
+
)
|
|
74
|
+
super().__init__(message, *args, **kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class UnexpectedStatusCode(SCIMResponseError):
|
|
78
|
+
"""Error raised when a server returned an unexpected status code for a
|
|
79
|
+
given :class:`~scim2_models.Context`."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, *args, **kwargs):
|
|
17
82
|
message = kwargs.pop(
|
|
18
|
-
"message",
|
|
83
|
+
"message",
|
|
84
|
+
f"Unexpected response status code: {kwargs['source'].status_code}",
|
|
19
85
|
)
|
|
20
|
-
super().__init__(
|
|
86
|
+
super().__init__(message, *args, **kwargs)
|
|
21
87
|
|
|
22
88
|
|
|
23
|
-
class UnexpectedContentType(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
):
|
|
30
|
-
content_type = response.headers.get("content-type", "")
|
|
89
|
+
class UnexpectedContentType(SCIMResponseError):
|
|
90
|
+
"""Error raised when a server returned an unexpected `Content-Type` header
|
|
91
|
+
in a response."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, *args, **kwargs):
|
|
94
|
+
content_type = kwargs["source"].headers.get("content-type", "")
|
|
31
95
|
message = kwargs.pop("message", f"Unexpected content type: {content_type}")
|
|
32
|
-
super().__init__(
|
|
96
|
+
super().__init__(message, *args, **kwargs)
|
|
97
|
+
|
|
33
98
|
|
|
99
|
+
class UnexpectedContentFormat(SCIMResponseError):
|
|
100
|
+
"""Error raised when a server returned a response in a non-JSON format."""
|
|
34
101
|
|
|
35
|
-
|
|
36
|
-
def __init__(
|
|
37
|
-
self,
|
|
38
|
-
response: Response,
|
|
39
|
-
*args,
|
|
40
|
-
**kwargs,
|
|
41
|
-
):
|
|
102
|
+
def __init__(self, *args, **kwargs):
|
|
42
103
|
message = kwargs.pop("message", "Unexpected response content format")
|
|
43
|
-
super().__init__(
|
|
104
|
+
super().__init__(message, *args, **kwargs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ResponsePayloadValidationError(SCIMResponseError):
|
|
108
|
+
"""Error raised when the server returned a payload that cannot be
|
|
109
|
+
validated.
|
|
110
|
+
|
|
111
|
+
This error is raised when a :class:`pydantic.ValidationError` has been catched
|
|
112
|
+
while validating the server response payload.
|
|
113
|
+
The original :class:`~pydantic.ValidationError` is available with :attr:`~BaseException.__cause__`.
|
|
114
|
+
|
|
115
|
+
.. code-block:: python
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
scim.query(User, "foobar")
|
|
119
|
+
except ResponsePayloadValidationError as exc:
|
|
120
|
+
print("Original validation error cause", exc.__cause__)
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(self, *args, **kwargs):
|
|
124
|
+
message = kwargs.pop("message", "Server response payload validation error")
|
|
125
|
+
super().__init__(message, *args, **kwargs)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: scim2-client
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: Pythonically build SCIM requests and parse SCIM responses
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: scim,scim2,provisioning,httpx,api
|
|
@@ -20,7 +20,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
21
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
22
22
|
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
23
|
-
Requires-Dist: scim2-models (>=0.1.
|
|
23
|
+
Requires-Dist: scim2-models (>=0.1.5,<0.2.0)
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
26
26
|
# scim2-client
|
|
@@ -28,6 +28,16 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
A SCIM client Python library built upon [scim2-models](https://scim2-models.readthedocs.io) and [httpx](https://github.com/encode/httpx),
|
|
29
29
|
that pythonically build requests and parse responses,
|
|
30
30
|
following the [RFC7643](https://datatracker.ietf.org/doc/html/rfc7643.html) and [RFC7644](https://datatracker.ietf.org/doc/html/rfc7644.html) specifications.
|
|
31
|
+
|
|
32
|
+
It aims to be used in SCIM client applications, or in unit tests for SCIM server applications.
|
|
33
|
+
|
|
34
|
+
## What's SCIM anyway?
|
|
35
|
+
|
|
36
|
+
SCIM stands for System for Cross-domain Identity Management, and it is a provisioning protocol.
|
|
37
|
+
Provisioning is the action of managing a set of resources across different services, usually users and groups.
|
|
38
|
+
SCIM is often used between Identity Providers and applications in completion of standards like OAuth2 and OpenID Connect.
|
|
39
|
+
It allows users and groups creations, modifications and deletions to be synchronized between applications.
|
|
40
|
+
|
|
31
41
|
## Installation
|
|
32
42
|
|
|
33
43
|
```shell
|
|
@@ -71,3 +81,8 @@ assert isinstance(response, Error)
|
|
|
71
81
|
assert response.detail == "One or more of the attribute values are already in use or are reserved."
|
|
72
82
|
```
|
|
73
83
|
|
|
84
|
+
scim2-client belongs in a collection of SCIM tools developed by [Yaal Coop](https://yaal.coop),
|
|
85
|
+
with [scim2-models](https://github.com/yaal-coop/scim2-models),
|
|
86
|
+
[scim2-tester](https://github.com/yaal-coop/scim2-tester) and
|
|
87
|
+
[scim2-cli](https://github.com/yaal-coop/scim2-cli)
|
|
88
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
scim2_client/__init__.py,sha256=vgrTJZIqJLZc72XCJwYKrSCGUDkMZTJjCJiZkHERZGs,780
|
|
2
|
+
scim2_client/client.py,sha256=_nGGGaKhwQSgJU1b56C2O859v-YC4YNDfm3uX5U0c8U,26975
|
|
3
|
+
scim2_client/errors.py,sha256=Lu_4lPlbryqWYGLPmH2iWaVmlay8S4PrH6lUPIvcisA,4430
|
|
4
|
+
scim2_client-0.1.6.dist-info/METADATA,sha256=DCymPPTKmd_PXLRfzblUqgBajOs7N8ZqSrNoANtcyQg,3495
|
|
5
|
+
scim2_client-0.1.6.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
6
|
+
scim2_client-0.1.6.dist-info/RECORD,,
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
scim2_client/__init__.py,sha256=2UNsl6HNtVUv5LVcnmLaCHyT4SqAUUFOIWW2r5XGv6A,338
|
|
2
|
-
scim2_client/client.py,sha256=HzsOyb4xqUC05-HuhV18g5abYK-00AY0iCni4-XBEtA,17880
|
|
3
|
-
scim2_client/errors.py,sha256=uOOAwsD8rDrC8BQbwPid051YyWtexTcS8b7i6QC6-CM,1175
|
|
4
|
-
scim2_client-0.1.4.dist-info/METADATA,sha256=khpZeya8xou5elKL0lN9VUC2sMS6KgYFyL690kH7GfA,2662
|
|
5
|
-
scim2_client-0.1.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
6
|
-
scim2_client-0.1.4.dist-info/RECORD,,
|
|
File without changes
|