scim2-server 0.3.0__tar.gz → 0.3.2__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.
- {scim2_server-0.3.0 → scim2_server-0.3.2}/PKG-INFO +3 -3
- {scim2_server-0.3.0 → scim2_server-0.3.2}/README.md +1 -1
- {scim2_server-0.3.0 → scim2_server-0.3.2}/pyproject.toml +13 -2
- {scim2_server-0.3.0 → scim2_server-0.3.2}/pyproject.toml.orig +15 -2
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/backend.py +72 -86
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/bulk.py +69 -49
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/cli.py +25 -8
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/provider.py +152 -118
- scim2_server-0.3.2/scim2_server/py.typed +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/utils.py +13 -2
- {scim2_server-0.3.0 → scim2_server-0.3.2}/LICENSE +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/__init__.py +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/resources/default-resource-types.json +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/resources/default-schemas.json +0 -0
- {scim2_server-0.3.0 → scim2_server-0.3.2}/scim2_server/resources/default-service-provider-config.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-server
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Lightweight SCIM2 server prototype
|
|
5
5
|
Keywords: scim,scim2,provisioning,rfc7643,rfc7644
|
|
6
6
|
Author: Yaal Coop, Christian Friedrich Coors
|
|
@@ -217,7 +217,7 @@ 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: scim2-models>=0.
|
|
220
|
+
Requires-Dist: scim2-models>=0.10.0
|
|
221
221
|
Requires-Dist: werkzeug>=3.0.3
|
|
222
222
|
Requires-Python: >=3.11
|
|
223
223
|
Project-URL: repository, https://github.com/python-scim/scim2-server
|
|
@@ -239,7 +239,7 @@ they are lost once the process exits.
|
|
|
239
239
|
- [x] Unique Constraints
|
|
240
240
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
241
241
|
- [x] Sorting
|
|
242
|
-
- [x] Bulk operations
|
|
242
|
+
- [x] Bulk operations
|
|
243
243
|
|
|
244
244
|
## Usage
|
|
245
245
|
|
|
@@ -13,7 +13,7 @@ they are lost once the process exits.
|
|
|
13
13
|
- [x] Unique Constraints
|
|
14
14
|
- [x] HTTP PATCH (Add/Remove/Replace)
|
|
15
15
|
- [x] Sorting
|
|
16
|
-
- [x] Bulk operations
|
|
16
|
+
- [x] Bulk operations
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
19
19
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
keywords = [
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
]
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"scim2-models>=0.
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
33
33
|
"werkzeug>=3.0.3",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -59,6 +59,7 @@ dev = [
|
|
|
59
59
|
"prek>=0.1.0",
|
|
60
60
|
"time-machine>=2.14.2",
|
|
61
61
|
"tox-uv>=1.16.0",
|
|
62
|
+
"mypy>=2.3.1",
|
|
62
63
|
]
|
|
63
64
|
|
|
64
65
|
[tool.uv]
|
|
@@ -75,6 +76,15 @@ pytest-scim2-server = false
|
|
|
75
76
|
[tool.uv.build-backend]
|
|
76
77
|
module-root = ""
|
|
77
78
|
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
plugins = ["pydantic.mypy"]
|
|
81
|
+
files = ["scim2_server"]
|
|
82
|
+
strict = true
|
|
83
|
+
|
|
84
|
+
[tool.pydantic-mypy]
|
|
85
|
+
init_forbid_extra = true
|
|
86
|
+
init_typed = true
|
|
87
|
+
|
|
78
88
|
[tool.coverage.run]
|
|
79
89
|
source = [
|
|
80
90
|
"scim2_server",
|
|
@@ -93,6 +103,7 @@ exclude_lines = [
|
|
|
93
103
|
"@pytest.mark.xfail",
|
|
94
104
|
"pragma: no cover",
|
|
95
105
|
"raise NotImplementedError",
|
|
106
|
+
"if TYPE_CHECKING:",
|
|
96
107
|
'\.\.\.\s*$',
|
|
97
108
|
]
|
|
98
109
|
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-server"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Lightweight SCIM2 server prototype"
|
|
9
9
|
authors = [
|
|
10
10
|
{name="Yaal Coop", email="contact@yaal.coop"},
|
|
@@ -29,7 +29,7 @@ classifiers = [
|
|
|
29
29
|
|
|
30
30
|
requires-python = ">= 3.11"
|
|
31
31
|
dependencies = [
|
|
32
|
-
"scim2-models>=0.
|
|
32
|
+
"scim2-models>=0.10.0",
|
|
33
33
|
"werkzeug>=3.0.3",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -48,6 +48,7 @@ dev = [
|
|
|
48
48
|
"prek>=0.1.0",
|
|
49
49
|
"time-machine>=2.14.2",
|
|
50
50
|
"tox-uv>=1.16.0",
|
|
51
|
+
"mypy>=2.3.1",
|
|
51
52
|
]
|
|
52
53
|
|
|
53
54
|
[tool.uv]
|
|
@@ -57,6 +58,17 @@ exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-serv
|
|
|
57
58
|
[tool.uv.build-backend]
|
|
58
59
|
module-root = ""
|
|
59
60
|
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
plugins = [
|
|
63
|
+
"pydantic.mypy"
|
|
64
|
+
]
|
|
65
|
+
files = ["scim2_server"]
|
|
66
|
+
strict = true
|
|
67
|
+
|
|
68
|
+
[tool.pydantic-mypy]
|
|
69
|
+
init_forbid_extra = true
|
|
70
|
+
init_typed = true
|
|
71
|
+
|
|
60
72
|
[tool.coverage.run]
|
|
61
73
|
source = [
|
|
62
74
|
"scim2_server",
|
|
@@ -71,6 +83,7 @@ exclude_lines = [
|
|
|
71
83
|
"@pytest.mark.xfail",
|
|
72
84
|
"pragma: no cover",
|
|
73
85
|
"raise NotImplementedError",
|
|
86
|
+
"if TYPE_CHECKING:",
|
|
74
87
|
"\\.\\.\\.\\s*$", # ignore ellipsis
|
|
75
88
|
]
|
|
76
89
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import dataclasses
|
|
2
1
|
import datetime
|
|
3
2
|
import pickle
|
|
4
3
|
import uuid
|
|
5
|
-
from inspect import isclass
|
|
6
4
|
from threading import Lock
|
|
5
|
+
from types import TracebackType
|
|
7
6
|
from typing import Any
|
|
7
|
+
from typing import Self
|
|
8
8
|
from typing import Union
|
|
9
|
+
from typing import cast
|
|
9
10
|
|
|
10
|
-
from scim2_models import
|
|
11
|
-
from scim2_models import CaseExact
|
|
12
|
-
from scim2_models import Extension
|
|
11
|
+
from scim2_models import AttributeBinding
|
|
13
12
|
from scim2_models import Meta
|
|
13
|
+
from scim2_models import Path
|
|
14
14
|
from scim2_models import Resource
|
|
15
15
|
from scim2_models import ResourceType
|
|
16
16
|
from scim2_models import ScimFilter
|
|
@@ -19,6 +19,8 @@ from scim2_models import Uniqueness
|
|
|
19
19
|
from scim2_models import UniquenessException
|
|
20
20
|
from werkzeug.http import generate_etag
|
|
21
21
|
|
|
22
|
+
from scim2_server.utils import parametrize
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
class Backend:
|
|
24
26
|
"""The base class for a SCIM provider backend.
|
|
@@ -27,22 +29,27 @@ class Backend:
|
|
|
27
29
|
:class:`~scim2_models.ScimProvider` of the application.
|
|
28
30
|
"""
|
|
29
31
|
|
|
30
|
-
def __enter__(self):
|
|
32
|
+
def __enter__(self) -> Self:
|
|
31
33
|
"""Allow the backend to be used as a context manager.
|
|
32
34
|
|
|
33
35
|
This enables support for transactions.
|
|
34
36
|
"""
|
|
35
37
|
return self
|
|
36
38
|
|
|
37
|
-
def __exit__(
|
|
39
|
+
def __exit__(
|
|
40
|
+
self,
|
|
41
|
+
exc_type: type[BaseException] | None,
|
|
42
|
+
exc_val: BaseException | None,
|
|
43
|
+
exc_tb: TracebackType | None,
|
|
44
|
+
) -> None:
|
|
38
45
|
"""Exit the transaction."""
|
|
39
46
|
pass
|
|
40
47
|
|
|
41
48
|
def query_resources(
|
|
42
49
|
self,
|
|
43
|
-
search_request: SearchRequest,
|
|
50
|
+
search_request: SearchRequest[Any],
|
|
44
51
|
resource_type: ResourceType | None = None,
|
|
45
|
-
) -> tuple[int, list[Resource]]:
|
|
52
|
+
) -> tuple[int, list[Resource[Any]]]:
|
|
46
53
|
"""Query the backend for a set of resources.
|
|
47
54
|
|
|
48
55
|
:param search_request: SearchRequest instance describing the
|
|
@@ -61,7 +68,7 @@ class Backend:
|
|
|
61
68
|
|
|
62
69
|
def get_resource(
|
|
63
70
|
self, resource_type: ResourceType, object_id: str
|
|
64
|
-
) -> Resource | None:
|
|
71
|
+
) -> Resource[Any] | None:
|
|
65
72
|
"""Query the backend for a resources by its ID.
|
|
66
73
|
|
|
67
74
|
:param resource_type: The resource type to get the object from.
|
|
@@ -83,8 +90,8 @@ class Backend:
|
|
|
83
90
|
raise NotImplementedError
|
|
84
91
|
|
|
85
92
|
def create_resource(
|
|
86
|
-
self, resource_type: ResourceType, resource: Resource
|
|
87
|
-
) -> Resource
|
|
93
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
94
|
+
) -> Resource[Any]:
|
|
88
95
|
"""Create a resource.
|
|
89
96
|
|
|
90
97
|
:param resource_type: The resource type to create.
|
|
@@ -96,15 +103,15 @@ class Backend:
|
|
|
96
103
|
raise NotImplementedError
|
|
97
104
|
|
|
98
105
|
def update_resource(
|
|
99
|
-
self, resource_type: ResourceType, resource: Resource
|
|
100
|
-
) -> Resource | None:
|
|
106
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
107
|
+
) -> Resource[Any] | None:
|
|
101
108
|
"""Update a resource. The resource is identified by its ID.
|
|
102
109
|
|
|
103
110
|
:param resource_type: The resource type to update.
|
|
104
111
|
:param resource: Resource to update.
|
|
105
|
-
:return: The updated resource
|
|
106
|
-
"meta.lastModified" data. May be
|
|
107
|
-
passed in.
|
|
112
|
+
:return: The updated resource, or None if no resource has its ID.
|
|
113
|
+
Updating should update the "meta.lastModified" data. May be
|
|
114
|
+
the same object that is passed in.
|
|
108
115
|
"""
|
|
109
116
|
raise NotImplementedError
|
|
110
117
|
|
|
@@ -118,60 +125,12 @@ class InMemoryBackend(Backend):
|
|
|
118
125
|
implementation simple.
|
|
119
126
|
"""
|
|
120
127
|
|
|
121
|
-
|
|
122
|
-
class UniquenessDescriptor:
|
|
123
|
-
"""Used to mimic uniqueness constraints e.g. from a SQL database."""
|
|
124
|
-
|
|
125
|
-
extension: str | None
|
|
126
|
-
field_name: str
|
|
127
|
-
case_exact: bool
|
|
128
|
-
schema: str
|
|
129
|
-
|
|
130
|
-
def is_declared_by(self, resource: Resource) -> bool:
|
|
131
|
-
"""Tell whether the model of a resource holds the schema of the attribute."""
|
|
132
|
-
model = type(resource)
|
|
133
|
-
return self.schema == model.__schema__ or (
|
|
134
|
-
self.schema in model.get_extension_models()
|
|
135
|
-
)
|
|
136
|
-
|
|
137
|
-
def get_attribute(self, resource: Resource) -> Any:
|
|
138
|
-
holder = getattr(resource, self.extension) if self.extension else resource
|
|
139
|
-
value = getattr(holder, self.field_name, None) if holder else None
|
|
140
|
-
if isinstance(value, str) and not self.case_exact:
|
|
141
|
-
return value.casefold()
|
|
142
|
-
return value
|
|
143
|
-
|
|
144
|
-
@classmethod
|
|
145
|
-
def collect_unique_attrs(
|
|
146
|
-
cls, model: type[BaseModel], extension: str | None = None
|
|
147
|
-
) -> list[UniquenessDescriptor]:
|
|
148
|
-
"""Return the uniqueness constraints the annotations of a model declare.
|
|
149
|
-
|
|
150
|
-
The ``id`` is left out: the backend issues it, so it cannot clash.
|
|
151
|
-
"""
|
|
152
|
-
descriptors = [
|
|
153
|
-
cls.UniquenessDescriptor(
|
|
154
|
-
extension,
|
|
155
|
-
field_name,
|
|
156
|
-
model.get_field_annotation(field_name, CaseExact) == CaseExact.true,
|
|
157
|
-
str(model.__schema__),
|
|
158
|
-
)
|
|
159
|
-
for field_name in model.model_fields
|
|
160
|
-
if field_name != "id"
|
|
161
|
-
and model.get_field_annotation(field_name, Uniqueness) != Uniqueness.none
|
|
162
|
-
]
|
|
163
|
-
for field_name in model.model_fields:
|
|
164
|
-
root_type = model.get_field_root_type(field_name)
|
|
165
|
-
if isclass(root_type) and issubclass(root_type, Extension):
|
|
166
|
-
descriptors.extend(cls.collect_unique_attrs(root_type, field_name))
|
|
167
|
-
return descriptors
|
|
168
|
-
|
|
169
|
-
def __init__(self):
|
|
128
|
+
def __init__(self) -> None:
|
|
170
129
|
super().__init__()
|
|
171
|
-
self.resources: list[Resource] = []
|
|
130
|
+
self.resources: list[Resource[Any]] = []
|
|
172
131
|
self.lock: Lock = Lock()
|
|
173
132
|
|
|
174
|
-
def __enter__(self):
|
|
133
|
+
def __enter__(self) -> Self:
|
|
175
134
|
"""See super docs.
|
|
176
135
|
|
|
177
136
|
The InMemoryBackend uses a simple Lock to synchronize all
|
|
@@ -181,15 +140,20 @@ class InMemoryBackend(Backend):
|
|
|
181
140
|
self.lock.acquire()
|
|
182
141
|
return self
|
|
183
142
|
|
|
184
|
-
def __exit__(
|
|
143
|
+
def __exit__(
|
|
144
|
+
self,
|
|
145
|
+
exc_type: type[BaseException] | None,
|
|
146
|
+
exc_val: BaseException | None,
|
|
147
|
+
exc_tb: TracebackType | None,
|
|
148
|
+
) -> None:
|
|
185
149
|
super().__exit__(exc_type, exc_val, exc_tb)
|
|
186
150
|
self.lock.release()
|
|
187
151
|
|
|
188
152
|
def query_resources(
|
|
189
153
|
self,
|
|
190
|
-
search_request: SearchRequest,
|
|
154
|
+
search_request: SearchRequest[Any],
|
|
191
155
|
resource_type: ResourceType | None = None,
|
|
192
|
-
) -> tuple[int, list[Resource]]:
|
|
156
|
+
) -> tuple[int, list[Resource[Any]]]:
|
|
193
157
|
start_index = (search_request.start_index or 1) - 1
|
|
194
158
|
|
|
195
159
|
candidates = [
|
|
@@ -201,7 +165,7 @@ class InMemoryBackend(Backend):
|
|
|
201
165
|
scim_filter = search_request.filter
|
|
202
166
|
if scim_filter is not None and not scim_filter.models and candidates:
|
|
203
167
|
models = tuple(dict.fromkeys(type(r) for r in candidates))
|
|
204
|
-
scim_filter = ScimFilter
|
|
168
|
+
scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
|
|
205
169
|
|
|
206
170
|
found_resources = [
|
|
207
171
|
r for r in candidates if scim_filter is None or scim_filter.match(r)
|
|
@@ -215,16 +179,17 @@ class InMemoryBackend(Backend):
|
|
|
215
179
|
found_resources = found_resources[: search_request.count]
|
|
216
180
|
return total_results, found_resources
|
|
217
181
|
|
|
218
|
-
def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
|
|
182
|
+
def _is_of_type(self, resource: Resource[Any], resource_type: ResourceType) -> bool:
|
|
219
183
|
"""Tell whether a resource belongs to a resource type.
|
|
220
184
|
|
|
221
185
|
RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
|
|
222
186
|
which may differ from its id.
|
|
223
187
|
"""
|
|
188
|
+
assert resource.meta is not None
|
|
224
189
|
return resource.meta.resource_type == resource_type.name
|
|
225
190
|
|
|
226
191
|
def _get_resource_idx(
|
|
227
|
-
self, resource_type: ResourceType, object_id: str
|
|
192
|
+
self, resource_type: ResourceType, object_id: str | None
|
|
228
193
|
) -> int | None:
|
|
229
194
|
return next(
|
|
230
195
|
(
|
|
@@ -237,7 +202,7 @@ class InMemoryBackend(Backend):
|
|
|
237
202
|
|
|
238
203
|
def get_resource(
|
|
239
204
|
self, resource_type: ResourceType, object_id: str
|
|
240
|
-
) -> Resource | None:
|
|
205
|
+
) -> Resource[Any] | None:
|
|
241
206
|
resource_dict_idx = self._get_resource_idx(resource_type, object_id)
|
|
242
207
|
if resource_dict_idx is not None:
|
|
243
208
|
return self.resources[resource_dict_idx].model_copy(deep=True)
|
|
@@ -255,11 +220,12 @@ class InMemoryBackend(Backend):
|
|
|
255
220
|
return False
|
|
256
221
|
|
|
257
222
|
def create_resource(
|
|
258
|
-
self, resource_type: ResourceType, resource: Resource
|
|
259
|
-
) -> Resource
|
|
223
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
224
|
+
) -> Resource[Any]:
|
|
260
225
|
resource = resource.model_copy(deep=True)
|
|
261
226
|
resource.id = uuid.uuid4().hex
|
|
262
227
|
utcnow = datetime.datetime.now(datetime.UTC)
|
|
228
|
+
assert resource_type.endpoint is not None
|
|
263
229
|
resource.meta = Meta(
|
|
264
230
|
resource_type=resource_type.name,
|
|
265
231
|
created=utcnow,
|
|
@@ -271,39 +237,59 @@ class InMemoryBackend(Backend):
|
|
|
271
237
|
self.resources.append(resource)
|
|
272
238
|
return resource
|
|
273
239
|
|
|
274
|
-
def _check_uniqueness(self, resource: Resource):
|
|
240
|
+
def _check_uniqueness(self, resource: Resource[Any]) -> None:
|
|
275
241
|
"""Refuse a resource sharing a unique value with another one of the same schema.
|
|
276
242
|
|
|
277
243
|
RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
|
|
278
244
|
schema that declares the attribute, whatever their resource type. A
|
|
279
245
|
missing value never clashes, as a SQL NULL does not.
|
|
280
246
|
"""
|
|
281
|
-
|
|
282
|
-
|
|
247
|
+
unique_paths = parametrize(Path, type(resource)).iter_paths(
|
|
248
|
+
include_subattributes=False,
|
|
249
|
+
uniqueness=[Uniqueness.server, Uniqueness.global_],
|
|
250
|
+
)
|
|
251
|
+
for path in unique_paths:
|
|
252
|
+
attribute = cast(AttributeBinding, path.resolve())
|
|
253
|
+
value = self._unique_value(resource, attribute)
|
|
283
254
|
if value is None:
|
|
284
255
|
continue
|
|
285
256
|
for existing_resource in self.resources:
|
|
286
257
|
if (
|
|
287
|
-
|
|
288
|
-
and existing_resource
|
|
289
|
-
and unique_attribute.get_attribute(existing_resource) == value
|
|
258
|
+
existing_resource.id != resource.id
|
|
259
|
+
and self._unique_value(existing_resource, attribute) == value
|
|
290
260
|
):
|
|
291
261
|
raise UniquenessException()
|
|
292
262
|
|
|
293
263
|
@staticmethod
|
|
294
|
-
def
|
|
264
|
+
def _unique_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
|
|
265
|
+
"""Return the value a resource holds for a unique attribute, in the form it is compared in.
|
|
266
|
+
|
|
267
|
+
A resource whose schemas do not declare the attribute holds no value.
|
|
268
|
+
"""
|
|
269
|
+
value = parametrize(Path, type(resource))(attribute.urn).get(
|
|
270
|
+
resource, strict=False
|
|
271
|
+
)
|
|
272
|
+
if isinstance(value, str) and not attribute.case_exact:
|
|
273
|
+
return value.casefold()
|
|
274
|
+
return value
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _touch_resource(
|
|
278
|
+
resource: Resource[Any], last_modified: datetime.datetime
|
|
279
|
+
) -> None:
|
|
295
280
|
"""Touches a resource (updates last_modified and version).
|
|
296
281
|
|
|
297
282
|
Version is generated by hashing last_modified. Another option
|
|
298
283
|
would be to hash the entire resource instead.
|
|
299
284
|
"""
|
|
285
|
+
assert resource.meta is not None
|
|
300
286
|
resource.meta.last_modified = last_modified
|
|
301
287
|
etag = generate_etag(pickle.dumps(resource.meta.last_modified))
|
|
302
288
|
resource.meta.version = f'W/"{etag}"'
|
|
303
289
|
|
|
304
290
|
def update_resource(
|
|
305
|
-
self, resource_type: ResourceType, resource: Resource
|
|
306
|
-
) -> Resource | None:
|
|
291
|
+
self, resource_type: ResourceType, resource: Resource[Any]
|
|
292
|
+
) -> Resource[Any] | None:
|
|
307
293
|
found_res_idx = self._get_resource_idx(resource_type, resource.id)
|
|
308
294
|
if found_res_idx is not None:
|
|
309
295
|
updated_resource = type(resource).model_validate(resource.model_dump())
|
|
@@ -1,55 +1,72 @@
|
|
|
1
1
|
from collections.abc import Callable
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from scim2_models import BulkOperation
|
|
4
6
|
from scim2_models import InvalidValueException
|
|
5
7
|
from scim2_models import Resource
|
|
6
8
|
from werkzeug.exceptions import Conflict
|
|
7
9
|
|
|
8
10
|
BULK_ID_PREFIX = "bulkId:"
|
|
9
11
|
|
|
10
|
-
Resolver = Callable[[Any], Any]
|
|
11
|
-
"""Replaces the bulkId references of
|
|
12
|
+
Resolver = Callable[[BulkOperation[Resource[Any]]], BulkOperation[Resource[Any]]]
|
|
13
|
+
"""Replaces the bulkId references of an operation."""
|
|
12
14
|
|
|
13
|
-
OperationRunner = Callable[
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"""Return an attribute of a raw payload, whose names are case insensitive."""
|
|
19
|
-
if not isinstance(payload, dict):
|
|
20
|
-
return None
|
|
21
|
-
return next(
|
|
22
|
-
(value for key, value in payload.items() if key.casefold() == name.casefold()),
|
|
23
|
-
None,
|
|
24
|
-
)
|
|
15
|
+
OperationRunner = Callable[
|
|
16
|
+
[BulkOperation[Resource[Any]], Resolver],
|
|
17
|
+
tuple[dict[str, Any], Resource[Any] | None],
|
|
18
|
+
]
|
|
19
|
+
"""Applies an operation once resolved, and returns its outcome and the resource it acted on."""
|
|
25
20
|
|
|
26
21
|
|
|
27
22
|
def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
|
|
28
|
-
"""Replace every "bulkId:" reference of a
|
|
23
|
+
"""Replace every "bulkId:" reference of a value.
|
|
24
|
+
|
|
25
|
+
A value without reference is returned as it is. Models are copied with
|
|
26
|
+
only the changed fields, so the fields the client set stay the same.
|
|
27
|
+
"""
|
|
29
28
|
if isinstance(value, str) and value.startswith(BULK_ID_PREFIX):
|
|
30
29
|
return replace(value.removeprefix(BULK_ID_PREFIX))
|
|
30
|
+
|
|
31
31
|
if isinstance(value, list):
|
|
32
|
-
|
|
32
|
+
items = [replace_bulk_ids(item, replace) for item in value]
|
|
33
|
+
changed = any(new is not old for new, old in zip(items, value, strict=True))
|
|
34
|
+
return items if changed else value
|
|
35
|
+
|
|
33
36
|
if isinstance(value, dict):
|
|
34
|
-
|
|
37
|
+
entries = {key: replace_bulk_ids(item, replace) for key, item in value.items()}
|
|
38
|
+
changed = any(entries[key] is not item for key, item in value.items())
|
|
39
|
+
return entries if changed else value
|
|
40
|
+
|
|
41
|
+
if isinstance(value, BaseModel):
|
|
42
|
+
updates = {}
|
|
43
|
+
for name in type(value).model_fields:
|
|
44
|
+
field = getattr(value, name)
|
|
45
|
+
replaced = replace_bulk_ids(field, replace)
|
|
46
|
+
if replaced is not field:
|
|
47
|
+
updates[name] = replaced
|
|
48
|
+
return value.model_copy(update=updates) if updates else value
|
|
49
|
+
|
|
35
50
|
return value
|
|
36
51
|
|
|
37
52
|
|
|
38
|
-
def resolve_operation(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
def resolve_operation(
|
|
54
|
+
operation: BulkOperation[Resource[Any]], replace: Callable[[str], str]
|
|
55
|
+
) -> BulkOperation[Resource[Any]]:
|
|
56
|
+
"""Replace the "bulkId:" references of the path and the data of a bulk operation."""
|
|
57
|
+
updates: dict[str, Any] = {}
|
|
58
|
+
if operation.path is not None:
|
|
59
|
+
path = "/".join(
|
|
60
|
+
replace_bulk_ids(segment, replace) for segment in operation.path.split("/")
|
|
61
|
+
)
|
|
62
|
+
if path != operation.path:
|
|
63
|
+
updates["path"] = path
|
|
42
64
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
elif key.casefold() == "data":
|
|
50
|
-
value = replace_bulk_ids(value, replace)
|
|
51
|
-
resolved[key] = value
|
|
52
|
-
return resolved
|
|
65
|
+
data = replace_bulk_ids(operation.data, replace)
|
|
66
|
+
if data is not operation.data:
|
|
67
|
+
updates["data"] = data
|
|
68
|
+
|
|
69
|
+
return operation.model_copy(update=updates) if updates else operation
|
|
53
70
|
|
|
54
71
|
|
|
55
72
|
class BulkJob:
|
|
@@ -63,7 +80,7 @@ class BulkJob:
|
|
|
63
80
|
|
|
64
81
|
def __init__(
|
|
65
82
|
self,
|
|
66
|
-
operations: list[Any],
|
|
83
|
+
operations: list[BulkOperation[Resource[Any]]],
|
|
67
84
|
fail_on_errors: int | None,
|
|
68
85
|
run: OperationRunner,
|
|
69
86
|
):
|
|
@@ -71,15 +88,17 @@ class BulkJob:
|
|
|
71
88
|
self.operations = operations
|
|
72
89
|
self.fail_on_errors = fail_on_errors
|
|
73
90
|
self.results: dict[int, dict[str, Any]] = {}
|
|
74
|
-
self.created: dict[str, Resource] = {}
|
|
91
|
+
self.created: dict[str, Resource[Any]] = {}
|
|
75
92
|
self.running: set[int] = set()
|
|
76
93
|
self.errors = 0
|
|
77
94
|
|
|
78
95
|
self.creations: dict[str, int] = {}
|
|
79
|
-
for index,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
for index, operation in enumerate(operations):
|
|
97
|
+
if (
|
|
98
|
+
operation.method == BulkOperation.Method.post
|
|
99
|
+
and operation.bulk_id is not None
|
|
100
|
+
):
|
|
101
|
+
self.creations.setdefault(operation.bulk_id, index)
|
|
83
102
|
|
|
84
103
|
@property
|
|
85
104
|
def stopped(self) -> bool:
|
|
@@ -105,15 +124,15 @@ class BulkJob:
|
|
|
105
124
|
if index in self.results or index in self.running or self.stopped:
|
|
106
125
|
return
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
operation = self.operations[index]
|
|
109
128
|
self.running.add(index)
|
|
110
|
-
for bulk_id in self.references(
|
|
129
|
+
for bulk_id in self.references(operation):
|
|
111
130
|
if bulk_id in self.creations:
|
|
112
131
|
self.run_operation(self.creations[bulk_id])
|
|
113
132
|
|
|
114
133
|
if not self.stopped:
|
|
115
134
|
result, resource = self.run_resolved(
|
|
116
|
-
|
|
135
|
+
operation, lambda operation: self.resolve(index, operation)
|
|
117
136
|
)
|
|
118
137
|
self.results[index] = result
|
|
119
138
|
if result["status"] >= 400:
|
|
@@ -127,7 +146,7 @@ class BulkJob:
|
|
|
127
146
|
return bulk_id is not None and self.creations.get(bulk_id) == index
|
|
128
147
|
|
|
129
148
|
@staticmethod
|
|
130
|
-
def references(
|
|
149
|
+
def references(operation: BulkOperation[Resource[Any]]) -> list[str]:
|
|
131
150
|
"""Return the bulkIds an operation references."""
|
|
132
151
|
bulk_ids: list[str] = []
|
|
133
152
|
|
|
@@ -135,23 +154,24 @@ class BulkJob:
|
|
|
135
154
|
bulk_ids.append(bulk_id)
|
|
136
155
|
return bulk_id
|
|
137
156
|
|
|
138
|
-
resolve_operation(
|
|
157
|
+
resolve_operation(operation, collect)
|
|
139
158
|
return bulk_ids
|
|
140
159
|
|
|
141
|
-
def resolve(
|
|
160
|
+
def resolve(
|
|
161
|
+
self, index: int, operation: BulkOperation[Resource[Any]]
|
|
162
|
+
) -> BulkOperation[Resource[Any]]:
|
|
142
163
|
"""Replace the bulkId references of an operation with the identifiers of the created resources.
|
|
143
164
|
|
|
144
165
|
:raises Conflict: When a referenced resource was not created, as
|
|
145
166
|
RFC 7644 §3.7.1 allows for circular references.
|
|
146
167
|
"""
|
|
147
|
-
bulk_id = raw_attribute(payload, "bulkId")
|
|
148
168
|
if (
|
|
149
|
-
|
|
150
|
-
and
|
|
151
|
-
and not self.is_creation(index, bulk_id)
|
|
169
|
+
operation.method == BulkOperation.Method.post
|
|
170
|
+
and operation.bulk_id is not None
|
|
171
|
+
and not self.is_creation(index, operation.bulk_id)
|
|
152
172
|
):
|
|
153
173
|
raise InvalidValueException(
|
|
154
|
-
detail=f"The bulkId {bulk_id} is not unique in the request"
|
|
174
|
+
detail=f"The bulkId {operation.bulk_id} is not unique in the request"
|
|
155
175
|
)
|
|
156
176
|
|
|
157
177
|
def replace(bulk_id: str) -> str:
|
|
@@ -161,4 +181,4 @@ class BulkJob:
|
|
|
161
181
|
raise Conflict(f"The bulkId {bulk_id} is part of a circular reference")
|
|
162
182
|
raise Conflict(f"No resource was created with the bulkId {bulk_id}")
|
|
163
183
|
|
|
164
|
-
return resolve_operation(
|
|
184
|
+
return resolve_operation(operation, replace)
|