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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-server
3
- Version: 0.3.0
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.9.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 ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
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 ([RFC 7644, Section 3.7](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7))
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.0"
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.9.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.0"
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.9.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 BaseModel
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__(self, exc_type, exc_val, exc_tb):
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 | None:
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. Updating should update the
106
- "meta.lastModified" data. May be the same object that is
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
- @dataclasses.dataclass(frozen=True)
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__(self, exc_type, exc_val, exc_tb):
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[Union[models]](str(scim_filter)) # noqa: UP007
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 | None:
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
- for unique_attribute in self.collect_unique_attrs(type(resource)):
282
- value = unique_attribute.get_attribute(resource)
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
- unique_attribute.is_declared_by(existing_resource)
288
- and existing_resource.id != resource.id
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 _touch_resource(resource: Resource, last_modified: datetime.datetime):
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 a raw operation."""
12
+ Resolver = Callable[[BulkOperation[Resource[Any]]], BulkOperation[Resource[Any]]]
13
+ """Replaces the bulkId references of an operation."""
12
14
 
13
- OperationRunner = Callable[[Any, Resolver], tuple[dict[str, Any], Resource | None]]
14
- """Applies a raw operation once resolved, and returns its outcome and the resource it acted on."""
15
-
16
-
17
- def raw_attribute(payload: Any, name: str) -> Any:
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 raw value."""
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
- return [replace_bulk_ids(item, replace) for item in value]
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
- return {key: replace_bulk_ids(item, replace) for key, item in value.items()}
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(payload: Any, replace: Callable[[str], str]) -> Any:
39
- """Replace the "bulkId:" references of the path and the data of a raw bulk operation."""
40
- if not isinstance(payload, dict):
41
- return payload
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
- resolved = {}
44
- for key, value in payload.items():
45
- if key.casefold() == "path" and isinstance(value, str):
46
- value = "/".join(
47
- replace_bulk_ids(segment, replace) for segment in value.split("/")
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, payload in enumerate(operations):
80
- bulk_id = raw_attribute(payload, "bulkId")
81
- if raw_attribute(payload, "method") == "POST" and isinstance(bulk_id, str):
82
- self.creations.setdefault(bulk_id, index)
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
- payload = self.operations[index]
127
+ operation = self.operations[index]
109
128
  self.running.add(index)
110
- for bulk_id in self.references(payload):
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
- payload, lambda payload: self.resolve(index, payload)
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(payload: Any) -> list[str]:
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(payload, collect)
157
+ resolve_operation(operation, collect)
139
158
  return bulk_ids
140
159
 
141
- def resolve(self, index: int, payload: Any) -> Any:
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
- raw_attribute(payload, "method") == "POST"
150
- and isinstance(bulk_id, str)
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(payload, replace)
184
+ return resolve_operation(operation, replace)