scim2-server 0.2.0__tar.gz → 0.3.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-server
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -208,7 +208,6 @@ License: Apache License
208
208
  limitations under the License.
209
209
  Classifier: Intended Audience :: Developers
210
210
  Classifier: Development Status :: 4 - Beta
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
@@ -218,10 +217,9 @@ Classifier: License :: OSI Approved :: Apache Software License
218
217
  Classifier: Environment :: Web Environment
219
218
  Classifier: Programming Language :: Python
220
219
  Classifier: Operating System :: OS Independent
221
- Requires-Dist: scim2-filter-parser>=0.7.0
222
- Requires-Dist: scim2-models>=0.6.1
220
+ Requires-Dist: scim2-models>=0.10.0
223
221
  Requires-Dist: werkzeug>=3.0.3
224
- Requires-Python: >=3.10
222
+ Requires-Python: >=3.11
225
223
  Project-URL: repository, https://github.com/python-scim/scim2-server
226
224
  Project-URL: funding, https://github.com/sponsors/python-scim
227
225
  Description-Content-Type: text/markdown
@@ -229,7 +227,7 @@ Description-Content-Type: text/markdown
229
227
  # scim2-server
230
228
 
231
229
  This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
232
- It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and [scim2-filter-parser](https://github.com/15five/scim2-filter-parser) and keeps all resources in-memory,
230
+ It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
233
231
  they are lost once the process exits.
234
232
 
235
233
  ## Features
@@ -241,20 +239,20 @@ they are lost once the process exits.
241
239
  - [x] Unique Constraints
242
240
  - [x] HTTP PATCH (Add/Remove/Replace)
243
241
  - [x] Sorting
244
-
245
- The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
242
+ - [x] Bulk operations
246
243
 
247
244
  ## Usage
248
245
 
249
246
  ```shell
250
- $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
247
+ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service-provider-config SERVICE_PROVIDER_CONFIG] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
251
248
  ```
252
249
 
253
250
  - `-h`/`--help`: Show help message
254
251
  - `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
255
252
  - `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
256
253
  - `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
257
- - `--bearer-token`: Registers a bearer token that can be used for accessing the service. If no tokens are provided, anonymous access without authentication is allowed.
254
+ - `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
255
+ - `--bearer-token`: Registers a bearer token that can be used for accessing the service, and announces the bearer token authentication scheme. If no tokens are provided, anonymous access without authentication is allowed.
258
256
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
259
257
  - `--port`: The port to listen on. Defaults to `8080`.
260
258
  - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
@@ -1,7 +1,7 @@
1
1
  # scim2-server
2
2
 
3
3
  This is an example WSGI-SCIM server using [scim2-models](https://github.com/python-scim/scim2-models).
4
- It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and [scim2-filter-parser](https://github.com/15five/scim2-filter-parser) and keeps all resources in-memory,
4
+ It utilizes [werkzeug](https://werkzeug.palletsprojects.com/) and keeps all resources in-memory,
5
5
  they are lost once the process exits.
6
6
 
7
7
  ## Features
@@ -13,20 +13,20 @@ 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
-
17
- The only optional feature currently missing is support for Bulk operations ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7)).
16
+ - [x] Bulk operations
18
17
 
19
18
  ## Usage
20
19
 
21
20
  ```shell
22
- $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
21
+ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--service-provider-config SERVICE_PROVIDER_CONFIG] [--bearer-token BEARER_TOKEN] [--hostname HOSTNAME] [--port PORT] [--reverse-proxy] [--dump-resources DUMP_RESOURCES] [--debug]
23
22
  ```
24
23
 
25
24
  - `-h`/`--help`: Show help message
26
25
  - `--reverse-proxy`: Allow using the provider behind a Reverse Proxy (required for URL rewriting).
27
26
  - `--schema`: Register schemas from specified JSON file. If not provided, loads the default schemas from RFC 7643.
28
27
  - `--resource-type`: Register resource types from specified JSON file. If not provided, loads the default resource types from RFC 7643.
29
- - `--bearer-token`: Registers a bearer token that can be used for accessing the service. If no tokens are provided, anonymous access without authentication is allowed.
28
+ - `--service-provider-config`: Load the service provider configuration from specified JSON file. If not provided, loads the default configuration.
29
+ - `--bearer-token`: Registers a bearer token that can be used for accessing the service, and announces the bearer token authentication scheme. If no tokens are provided, anonymous access without authentication is allowed.
30
30
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
31
31
  - `--port`: The port to listen on. Defaults to `8080`.
32
32
  - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
@@ -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-server"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Lightweight SCIM2 server prototype"
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 :: 4 - Beta",
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,10 +27,9 @@ 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
- "scim2-filter-parser>=0.7.0",
34
- "scim2-models>=0.6.1",
32
+ "scim2-models>=0.10.0",
35
33
  "werkzeug>=3.0.3",
36
34
  ]
37
35
 
@@ -55,14 +53,25 @@ scim2-server = "scim2_server.cli:main"
55
53
 
56
54
  [dependency-groups]
57
55
  dev = [
58
- "httpx>=0.27.0",
56
+ "httpx2>=2.0.0",
59
57
  "pytest>=8.2.2",
60
- "pytest-coverage>=0.0",
58
+ "pytest-cov>=6.0.0",
61
59
  "prek>=0.1.0",
62
60
  "time-machine>=2.14.2",
63
61
  "tox-uv>=1.16.0",
64
62
  ]
65
63
 
64
+ [tool.uv]
65
+ exclude-newer = "14 days"
66
+
67
+ [tool.uv.exclude-newer-package]
68
+ scim2-models = false
69
+ scim2-client = false
70
+ scim2-server = false
71
+ scim2-cli = false
72
+ scim2-tester = false
73
+ pytest-scim2-server = false
74
+
66
75
  [tool.uv.build-backend]
67
76
  module-root = ""
68
77
 
@@ -125,7 +134,6 @@ doctest_optionflags = "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
125
134
  requires = ["tox>=4.19"]
126
135
  env_list = [
127
136
  "style",
128
- "py310",
129
137
  "py311",
130
138
  "py312",
131
139
  "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-server"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -16,7 +16,6 @@ keywords = ["scim", "scim2", "provisioning", "rfc7643", "rfc7644"]
16
16
  classifiers = [
17
17
  "Intended Audience :: Developers",
18
18
  "Development Status :: 4 - Beta",
19
- "Programming Language :: Python :: 3.10",
20
19
  "Programming Language :: Python :: 3.11",
21
20
  "Programming Language :: Python :: 3.12",
22
21
  "Programming Language :: Python :: 3.13",
@@ -28,10 +27,9 @@ classifiers = [
28
27
  "Operating System :: OS Independent",
29
28
  ]
30
29
 
31
- requires-python = ">= 3.10"
30
+ requires-python = ">= 3.11"
32
31
  dependencies = [
33
- "scim2-filter-parser>=0.7.0",
34
- "scim2-models>=0.6.1",
32
+ "scim2-models>=0.10.0",
35
33
  "werkzeug>=3.0.3",
36
34
  ]
37
35
 
@@ -44,14 +42,18 @@ scim2-server = "scim2_server.cli:main"
44
42
 
45
43
  [dependency-groups]
46
44
  dev = [
47
- "httpx>=0.27.0",
45
+ "httpx2>=2.0.0",
48
46
  "pytest>=8.2.2",
49
- "pytest-coverage>=0.0",
47
+ "pytest-cov>=6.0.0",
50
48
  "prek>=0.1.0",
51
49
  "time-machine>=2.14.2",
52
50
  "tox-uv>=1.16.0",
53
51
  ]
54
52
 
53
+ [tool.uv]
54
+ exclude-newer = "14 days"
55
+ exclude-newer-package = { scim2-models = false, scim2-client = false, scim2-server = false, scim2-cli = false, scim2-tester = false, pytest-scim2-server = false }
56
+
55
57
  [tool.uv.build-backend]
56
58
  module-root = ""
57
59
 
@@ -111,7 +113,6 @@ doctest_optionflags= "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
111
113
  requires = ["tox>=4.19"]
112
114
  env_list = [
113
115
  "style",
114
- "py310",
115
116
  "py311",
116
117
  "py312",
117
118
  "py313",
@@ -0,0 +1,280 @@
1
+ import datetime
2
+ import pickle
3
+ import uuid
4
+ from threading import Lock
5
+ from typing import Any
6
+ from typing import Union
7
+ from typing import cast
8
+
9
+ from scim2_models import AttributeBinding
10
+ from scim2_models import Meta
11
+ from scim2_models import Path
12
+ from scim2_models import Resource
13
+ from scim2_models import ResourceType
14
+ from scim2_models import ScimFilter
15
+ from scim2_models import SearchRequest
16
+ from scim2_models import Uniqueness
17
+ from scim2_models import UniquenessException
18
+ from werkzeug.http import generate_etag
19
+
20
+
21
+ class Backend:
22
+ """The base class for a SCIM provider backend.
23
+
24
+ A backend only stores resources: what the service serves is described by the
25
+ :class:`~scim2_models.ScimProvider` of the application.
26
+ """
27
+
28
+ def __enter__(self):
29
+ """Allow the backend to be used as a context manager.
30
+
31
+ This enables support for transactions.
32
+ """
33
+ return self
34
+
35
+ def __exit__(self, exc_type, exc_val, exc_tb):
36
+ """Exit the transaction."""
37
+ pass
38
+
39
+ def query_resources(
40
+ self,
41
+ search_request: SearchRequest,
42
+ resource_type: ResourceType | None = None,
43
+ ) -> tuple[int, list[Resource]]:
44
+ """Query the backend for a set of resources.
45
+
46
+ :param search_request: SearchRequest instance describing the
47
+ query.
48
+ :param resource_type: The resource type to query. If None, all
49
+ resource types are queried.
50
+ :return: A tuple of "total results" and a List of found
51
+ Resources. The List must contain a copy of resources.
52
+ Mutating elements in the List must not modify the data
53
+ stored in the backend.
54
+ :raises TooManyException: If the backend only supports querying
55
+ for one resource type at a time, setting resource_type to
56
+ None the backend may raise TooManyException.
57
+ """
58
+ raise NotImplementedError
59
+
60
+ def get_resource(
61
+ self, resource_type: ResourceType, object_id: str
62
+ ) -> Resource | None:
63
+ """Query the backend for a resources by its ID.
64
+
65
+ :param resource_type: The resource type to get the object from.
66
+ :param object_id: ID of the object to get.
67
+ :return: The resource object if it exists, None otherwise. The
68
+ resource must be a copy, modifying it must not change the
69
+ data stored in the backend.
70
+ """
71
+ raise NotImplementedError
72
+
73
+ def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
74
+ """Delete a resource.
75
+
76
+ :param resource_type: The resource type to delete the object
77
+ from.
78
+ :param object_id: ID of the object to delete.
79
+ :return: True if the resource was deleted, False otherwise.
80
+ """
81
+ raise NotImplementedError
82
+
83
+ def create_resource(
84
+ self, resource_type: ResourceType, resource: Resource
85
+ ) -> Resource | None:
86
+ """Create a resource.
87
+
88
+ :param resource_type: The resource type to create.
89
+ :param resource: Resource to create.
90
+ :return: The created resource. Creation should set system-
91
+ defined attributes (ID, Metadata). May be the same object
92
+ that is passed in.
93
+ """
94
+ raise NotImplementedError
95
+
96
+ def update_resource(
97
+ self, resource_type: ResourceType, resource: Resource
98
+ ) -> Resource | None:
99
+ """Update a resource. The resource is identified by its ID.
100
+
101
+ :param resource_type: The resource type to update.
102
+ :param resource: Resource to update.
103
+ :return: The updated resource. Updating should update the
104
+ "meta.lastModified" data. May be the same object that is
105
+ passed in.
106
+ """
107
+ raise NotImplementedError
108
+
109
+
110
+ class InMemoryBackend(Backend):
111
+ """An example in-memory backend for the SCIM provider.
112
+
113
+ It is not optimized for performance. Many operations are O(n) or
114
+ worse, whereas they would perform better with an actual production
115
+ database in the backend. This is intentional to keep the
116
+ implementation simple.
117
+ """
118
+
119
+ def __init__(self):
120
+ super().__init__()
121
+ self.resources: list[Resource] = []
122
+ self.lock: Lock = Lock()
123
+
124
+ def __enter__(self):
125
+ """See super docs.
126
+
127
+ The InMemoryBackend uses a simple Lock to synchronize all
128
+ access.
129
+ """
130
+ super().__enter__()
131
+ self.lock.acquire()
132
+ return self
133
+
134
+ def __exit__(self, exc_type, exc_val, exc_tb):
135
+ super().__exit__(exc_type, exc_val, exc_tb)
136
+ self.lock.release()
137
+
138
+ def query_resources(
139
+ self,
140
+ search_request: SearchRequest,
141
+ resource_type: ResourceType | None = None,
142
+ ) -> tuple[int, list[Resource]]:
143
+ start_index = (search_request.start_index or 1) - 1
144
+
145
+ candidates = [
146
+ r
147
+ for r in self.resources
148
+ if resource_type is None or self._is_of_type(r, resource_type)
149
+ ]
150
+
151
+ scim_filter = search_request.filter
152
+ if scim_filter is not None and not scim_filter.models and candidates:
153
+ models = tuple(dict.fromkeys(type(r) for r in candidates))
154
+ scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
155
+
156
+ found_resources = [
157
+ r for r in candidates if scim_filter is None or scim_filter.match(r)
158
+ ]
159
+
160
+ found_resources = search_request.sort(found_resources)
161
+
162
+ total_results = len(found_resources)
163
+ found_resources = found_resources[start_index:]
164
+ if search_request.count is not None:
165
+ found_resources = found_resources[: search_request.count]
166
+ return total_results, found_resources
167
+
168
+ def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
169
+ """Tell whether a resource belongs to a resource type.
170
+
171
+ RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
172
+ which may differ from its id.
173
+ """
174
+ return resource.meta.resource_type == resource_type.name
175
+
176
+ def _get_resource_idx(
177
+ self, resource_type: ResourceType, object_id: str
178
+ ) -> int | None:
179
+ return next(
180
+ (
181
+ idx
182
+ for idx, r in enumerate(self.resources)
183
+ if self._is_of_type(r, resource_type) and r.id == object_id
184
+ ),
185
+ None,
186
+ )
187
+
188
+ def get_resource(
189
+ self, resource_type: ResourceType, object_id: str
190
+ ) -> Resource | None:
191
+ resource_dict_idx = self._get_resource_idx(resource_type, object_id)
192
+ if resource_dict_idx is not None:
193
+ return self.resources[resource_dict_idx].model_copy(deep=True)
194
+ return None
195
+
196
+ def delete_resource(self, resource_type: ResourceType, object_id: str) -> bool:
197
+ found = self.get_resource(resource_type, object_id)
198
+ if found:
199
+ self.resources = [
200
+ r
201
+ for r in self.resources
202
+ if not (self._is_of_type(r, resource_type) and r.id == object_id)
203
+ ]
204
+ return True
205
+ return False
206
+
207
+ def create_resource(
208
+ self, resource_type: ResourceType, resource: Resource
209
+ ) -> Resource | None:
210
+ resource = resource.model_copy(deep=True)
211
+ resource.id = uuid.uuid4().hex
212
+ utcnow = datetime.datetime.now(datetime.UTC)
213
+ resource.meta = Meta(
214
+ resource_type=resource_type.name,
215
+ created=utcnow,
216
+ last_modified=utcnow,
217
+ location="/v2" + resource_type.endpoint + "/" + resource.id,
218
+ )
219
+ self._touch_resource(resource, utcnow)
220
+ self._check_uniqueness(resource)
221
+ self.resources.append(resource)
222
+ return resource
223
+
224
+ def _check_uniqueness(self, resource: Resource):
225
+ """Refuse a resource sharing a unique value with another one of the same schema.
226
+
227
+ RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
228
+ schema that declares the attribute, whatever their resource type. A
229
+ missing value never clashes, as a SQL NULL does not.
230
+ """
231
+ unique_paths = Path[type(resource)].iter_paths(
232
+ include_subattributes=False,
233
+ uniqueness=[Uniqueness.server, Uniqueness.global_],
234
+ )
235
+ for path in unique_paths:
236
+ attribute = cast(AttributeBinding, path.resolve())
237
+ value = self._unique_value(resource, attribute)
238
+ if value is None:
239
+ continue
240
+ for existing_resource in self.resources:
241
+ if (
242
+ existing_resource.id != resource.id
243
+ and self._unique_value(existing_resource, attribute) == value
244
+ ):
245
+ raise UniquenessException()
246
+
247
+ @staticmethod
248
+ def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
249
+ """Return the value a resource holds for a unique attribute, in the form it is compared in.
250
+
251
+ A resource whose schemas do not declare the attribute holds no value.
252
+ """
253
+ value = Path[type(resource)](attribute.urn).get(resource, strict=False)
254
+ if isinstance(value, str) and not attribute.case_exact:
255
+ return value.casefold()
256
+ return value
257
+
258
+ @staticmethod
259
+ def _touch_resource(resource: Resource, last_modified: datetime.datetime):
260
+ """Touches a resource (updates last_modified and version).
261
+
262
+ Version is generated by hashing last_modified. Another option
263
+ would be to hash the entire resource instead.
264
+ """
265
+ resource.meta.last_modified = last_modified
266
+ etag = generate_etag(pickle.dumps(resource.meta.last_modified))
267
+ resource.meta.version = f'W/"{etag}"'
268
+
269
+ def update_resource(
270
+ self, resource_type: ResourceType, resource: Resource
271
+ ) -> Resource | None:
272
+ found_res_idx = self._get_resource_idx(resource_type, resource.id)
273
+ if found_res_idx is not None:
274
+ updated_resource = type(resource).model_validate(resource.model_dump())
275
+ self._touch_resource(updated_resource, datetime.datetime.now(datetime.UTC))
276
+
277
+ self._check_uniqueness(updated_resource)
278
+ self.resources[found_res_idx] = updated_resource
279
+ return updated_resource
280
+ return None
@@ -0,0 +1,181 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel
5
+ from scim2_models import BulkOperation
6
+ from scim2_models import InvalidValueException
7
+ from scim2_models import Resource
8
+ from werkzeug.exceptions import Conflict
9
+
10
+ BULK_ID_PREFIX = "bulkId:"
11
+
12
+ Resolver = Callable[[BulkOperation], BulkOperation]
13
+ """Replaces the bulkId references of an operation."""
14
+
15
+ OperationRunner = Callable[
16
+ [BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
17
+ ]
18
+ """Applies an operation once resolved, and returns its outcome and the resource it acted on."""
19
+
20
+
21
+ def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
22
+ """Replace every "bulkId:" reference of a value.
23
+
24
+ A value without reference is returned as it is. Models are copied with
25
+ only the changed fields, so the fields the client set stay the same.
26
+ """
27
+ if isinstance(value, str) and value.startswith(BULK_ID_PREFIX):
28
+ return replace(value.removeprefix(BULK_ID_PREFIX))
29
+
30
+ if isinstance(value, list):
31
+ items = [replace_bulk_ids(item, replace) for item in value]
32
+ changed = any(new is not old for new, old in zip(items, value, strict=True))
33
+ return items if changed else value
34
+
35
+ if isinstance(value, dict):
36
+ entries = {key: replace_bulk_ids(item, replace) for key, item in value.items()}
37
+ changed = any(entries[key] is not item for key, item in value.items())
38
+ return entries if changed else value
39
+
40
+ if isinstance(value, BaseModel):
41
+ updates = {}
42
+ for name in type(value).model_fields:
43
+ field = getattr(value, name)
44
+ replaced = replace_bulk_ids(field, replace)
45
+ if replaced is not field:
46
+ updates[name] = replaced
47
+ return value.model_copy(update=updates) if updates else value
48
+
49
+ return value
50
+
51
+
52
+ def resolve_operation(
53
+ operation: BulkOperation, replace: Callable[[str], str]
54
+ ) -> BulkOperation:
55
+ """Replace the "bulkId:" references of the path and the data of a bulk operation."""
56
+ updates: dict[str, Any] = {}
57
+ if operation.path is not None:
58
+ path = "/".join(
59
+ replace_bulk_ids(segment, replace) for segment in operation.path.split("/")
60
+ )
61
+ if path != operation.path:
62
+ updates["path"] = path
63
+
64
+ data = replace_bulk_ids(operation.data, replace)
65
+ if data is not operation.data:
66
+ updates["data"] = data
67
+
68
+ return operation.model_copy(update=updates) if updates else operation
69
+
70
+
71
+ class BulkJob:
72
+ """Run the operations of a bulk request, and resolve their "bulkId:" references.
73
+
74
+ RFC 7644 §3.7.2 lets an operation reference a resource that another POST
75
+ of the same request creates. The operations run in the order of the
76
+ request, except that a POST runs before the first operation that
77
+ references it. The results keep the order of the request.
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ operations: list[BulkOperation],
83
+ fail_on_errors: int | None,
84
+ run: OperationRunner,
85
+ ):
86
+ self.run_resolved = run
87
+ self.operations = operations
88
+ self.fail_on_errors = fail_on_errors
89
+ self.results: dict[int, dict[str, Any]] = {}
90
+ self.created: dict[str, Resource] = {}
91
+ self.running: set[int] = set()
92
+ self.errors = 0
93
+
94
+ self.creations: dict[str, int] = {}
95
+ for index, operation in enumerate(operations):
96
+ if (
97
+ operation.method == BulkOperation.Method.post
98
+ and operation.bulk_id is not None
99
+ ):
100
+ self.creations.setdefault(operation.bulk_id, index)
101
+
102
+ @property
103
+ def stopped(self) -> bool:
104
+ """Whether the job reached the number of errors the client accepts.
105
+
106
+ RFC 7644 §3.7.3: the job goes on despite failures, unless the client
107
+ caps the errors it accepts with "failOnErrors".
108
+ """
109
+ return (
110
+ self.errors > 0
111
+ and self.fail_on_errors is not None
112
+ and self.errors >= self.fail_on_errors
113
+ )
114
+
115
+ def run(self) -> list[dict[str, Any]]:
116
+ """Run every operation, and return the results of the operations that ran."""
117
+ for index in range(len(self.operations)):
118
+ self.run_operation(index)
119
+ return [self.results[index] for index in sorted(self.results)]
120
+
121
+ def run_operation(self, index: int) -> None:
122
+ """Run an operation, after the creations it references."""
123
+ if index in self.results or index in self.running or self.stopped:
124
+ return
125
+
126
+ operation = self.operations[index]
127
+ self.running.add(index)
128
+ for bulk_id in self.references(operation):
129
+ if bulk_id in self.creations:
130
+ self.run_operation(self.creations[bulk_id])
131
+
132
+ if not self.stopped:
133
+ result, resource = self.run_resolved(
134
+ operation, lambda operation: self.resolve(index, operation)
135
+ )
136
+ self.results[index] = result
137
+ if result["status"] >= 400:
138
+ self.errors += 1
139
+ elif resource is not None and self.is_creation(index, result["bulk_id"]):
140
+ self.created[result["bulk_id"]] = resource
141
+ self.running.discard(index)
142
+
143
+ def is_creation(self, index: int, bulk_id: str | None) -> bool:
144
+ """Whether an operation is the creation a bulkId references."""
145
+ return bulk_id is not None and self.creations.get(bulk_id) == index
146
+
147
+ @staticmethod
148
+ def references(operation: BulkOperation) -> list[str]:
149
+ """Return the bulkIds an operation references."""
150
+ bulk_ids: list[str] = []
151
+
152
+ def collect(bulk_id: str) -> str:
153
+ bulk_ids.append(bulk_id)
154
+ return bulk_id
155
+
156
+ resolve_operation(operation, collect)
157
+ return bulk_ids
158
+
159
+ def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
160
+ """Replace the bulkId references of an operation with the identifiers of the created resources.
161
+
162
+ :raises Conflict: When a referenced resource was not created, as
163
+ RFC 7644 §3.7.1 allows for circular references.
164
+ """
165
+ if (
166
+ operation.method == BulkOperation.Method.post
167
+ and operation.bulk_id is not None
168
+ and not self.is_creation(index, operation.bulk_id)
169
+ ):
170
+ raise InvalidValueException(
171
+ detail=f"The bulkId {operation.bulk_id} is not unique in the request"
172
+ )
173
+
174
+ def replace(bulk_id: str) -> str:
175
+ if bulk_id in self.created:
176
+ return str(self.created[bulk_id].id)
177
+ if self.creations.get(bulk_id) in self.running:
178
+ raise Conflict(f"The bulkId {bulk_id} is part of a circular reference")
179
+ raise Conflict(f"No resource was created with the bulkId {bulk_id}")
180
+
181
+ return resolve_operation(operation, replace)