scim2-server 0.3.1__tar.gz → 0.3.3__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.1
3
+ Version: 0.3.3
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.3.1"
7
+ version = "0.3.3"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -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.1"
7
+ version = "0.3.3"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -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
 
@@ -2,7 +2,9 @@ import datetime
2
2
  import pickle
3
3
  import uuid
4
4
  from threading import Lock
5
+ from types import TracebackType
5
6
  from typing import Any
7
+ from typing import Self
6
8
  from typing import Union
7
9
  from typing import cast
8
10
 
@@ -17,6 +19,8 @@ from scim2_models import Uniqueness
17
19
  from scim2_models import UniquenessException
18
20
  from werkzeug.http import generate_etag
19
21
 
22
+ from scim2_server.utils import parametrize
23
+
20
24
 
21
25
  class Backend:
22
26
  """The base class for a SCIM provider backend.
@@ -25,22 +29,27 @@ class Backend:
25
29
  :class:`~scim2_models.ScimProvider` of the application.
26
30
  """
27
31
 
28
- def __enter__(self):
32
+ def __enter__(self) -> Self:
29
33
  """Allow the backend to be used as a context manager.
30
34
 
31
35
  This enables support for transactions.
32
36
  """
33
37
  return self
34
38
 
35
- 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:
36
45
  """Exit the transaction."""
37
46
  pass
38
47
 
39
48
  def query_resources(
40
49
  self,
41
- search_request: SearchRequest,
50
+ search_request: SearchRequest[Any],
42
51
  resource_type: ResourceType | None = None,
43
- ) -> tuple[int, list[Resource]]:
52
+ ) -> tuple[int, list[Resource[Any]]]:
44
53
  """Query the backend for a set of resources.
45
54
 
46
55
  :param search_request: SearchRequest instance describing the
@@ -59,7 +68,7 @@ class Backend:
59
68
 
60
69
  def get_resource(
61
70
  self, resource_type: ResourceType, object_id: str
62
- ) -> Resource | None:
71
+ ) -> Resource[Any] | None:
63
72
  """Query the backend for a resources by its ID.
64
73
 
65
74
  :param resource_type: The resource type to get the object from.
@@ -81,8 +90,8 @@ class Backend:
81
90
  raise NotImplementedError
82
91
 
83
92
  def create_resource(
84
- self, resource_type: ResourceType, resource: Resource
85
- ) -> Resource | None:
93
+ self, resource_type: ResourceType, resource: Resource[Any]
94
+ ) -> Resource[Any]:
86
95
  """Create a resource.
87
96
 
88
97
  :param resource_type: The resource type to create.
@@ -94,15 +103,15 @@ class Backend:
94
103
  raise NotImplementedError
95
104
 
96
105
  def update_resource(
97
- self, resource_type: ResourceType, resource: Resource
98
- ) -> Resource | None:
106
+ self, resource_type: ResourceType, resource: Resource[Any]
107
+ ) -> Resource[Any] | None:
99
108
  """Update a resource. The resource is identified by its ID.
100
109
 
101
110
  :param resource_type: The resource type to update.
102
111
  :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.
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.
106
115
  """
107
116
  raise NotImplementedError
108
117
 
@@ -116,12 +125,12 @@ class InMemoryBackend(Backend):
116
125
  implementation simple.
117
126
  """
118
127
 
119
- def __init__(self):
128
+ def __init__(self) -> None:
120
129
  super().__init__()
121
- self.resources: list[Resource] = []
130
+ self.resources: list[Resource[Any]] = []
122
131
  self.lock: Lock = Lock()
123
132
 
124
- def __enter__(self):
133
+ def __enter__(self) -> Self:
125
134
  """See super docs.
126
135
 
127
136
  The InMemoryBackend uses a simple Lock to synchronize all
@@ -131,15 +140,20 @@ class InMemoryBackend(Backend):
131
140
  self.lock.acquire()
132
141
  return self
133
142
 
134
- 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:
135
149
  super().__exit__(exc_type, exc_val, exc_tb)
136
150
  self.lock.release()
137
151
 
138
152
  def query_resources(
139
153
  self,
140
- search_request: SearchRequest,
154
+ search_request: SearchRequest[Any],
141
155
  resource_type: ResourceType | None = None,
142
- ) -> tuple[int, list[Resource]]:
156
+ ) -> tuple[int, list[Resource[Any]]]:
143
157
  start_index = (search_request.start_index or 1) - 1
144
158
 
145
159
  candidates = [
@@ -151,7 +165,7 @@ class InMemoryBackend(Backend):
151
165
  scim_filter = search_request.filter
152
166
  if scim_filter is not None and not scim_filter.models and candidates:
153
167
  models = tuple(dict.fromkeys(type(r) for r in candidates))
154
- scim_filter = ScimFilter[Union[models]](str(scim_filter)) # noqa: UP007
168
+ scim_filter = parametrize(ScimFilter, Union[models])(str(scim_filter)) # noqa: UP007
155
169
 
156
170
  found_resources = [
157
171
  r for r in candidates if scim_filter is None or scim_filter.match(r)
@@ -165,16 +179,17 @@ class InMemoryBackend(Backend):
165
179
  found_resources = found_resources[: search_request.count]
166
180
  return total_results, found_resources
167
181
 
168
- def _is_of_type(self, resource: Resource, resource_type: ResourceType) -> bool:
182
+ def _is_of_type(self, resource: Resource[Any], resource_type: ResourceType) -> bool:
169
183
  """Tell whether a resource belongs to a resource type.
170
184
 
171
185
  RFC 7643 §3.1 has meta.resourceType carry the name of the resource type,
172
186
  which may differ from its id.
173
187
  """
188
+ assert resource.meta is not None
174
189
  return resource.meta.resource_type == resource_type.name
175
190
 
176
191
  def _get_resource_idx(
177
- self, resource_type: ResourceType, object_id: str
192
+ self, resource_type: ResourceType, object_id: str | None
178
193
  ) -> int | None:
179
194
  return next(
180
195
  (
@@ -187,7 +202,7 @@ class InMemoryBackend(Backend):
187
202
 
188
203
  def get_resource(
189
204
  self, resource_type: ResourceType, object_id: str
190
- ) -> Resource | None:
205
+ ) -> Resource[Any] | None:
191
206
  resource_dict_idx = self._get_resource_idx(resource_type, object_id)
192
207
  if resource_dict_idx is not None:
193
208
  return self.resources[resource_dict_idx].model_copy(deep=True)
@@ -205,11 +220,12 @@ class InMemoryBackend(Backend):
205
220
  return False
206
221
 
207
222
  def create_resource(
208
- self, resource_type: ResourceType, resource: Resource
209
- ) -> Resource | None:
223
+ self, resource_type: ResourceType, resource: Resource[Any]
224
+ ) -> Resource[Any]:
210
225
  resource = resource.model_copy(deep=True)
211
226
  resource.id = uuid.uuid4().hex
212
227
  utcnow = datetime.datetime.now(datetime.UTC)
228
+ assert resource_type.endpoint is not None
213
229
  resource.meta = Meta(
214
230
  resource_type=resource_type.name,
215
231
  created=utcnow,
@@ -221,14 +237,14 @@ class InMemoryBackend(Backend):
221
237
  self.resources.append(resource)
222
238
  return resource
223
239
 
224
- def _check_uniqueness(self, resource: Resource):
240
+ def _check_uniqueness(self, resource: Resource[Any]) -> None:
225
241
  """Refuse a resource sharing a unique value with another one of the same schema.
226
242
 
227
243
  RFC 7643 erratum 8279 scopes the uniqueness to the resources using the
228
244
  schema that declares the attribute, whatever their resource type. A
229
245
  missing value never clashes, as a SQL NULL does not.
230
246
  """
231
- unique_paths = Path[type(resource)].iter_paths(
247
+ unique_paths = parametrize(Path, type(resource)).iter_paths(
232
248
  include_subattributes=False,
233
249
  uniqueness=[Uniqueness.server, Uniqueness.global_],
234
250
  )
@@ -245,30 +261,35 @@ class InMemoryBackend(Backend):
245
261
  raise UniquenessException()
246
262
 
247
263
  @staticmethod
248
- def _unique_value(resource: Resource, attribute: AttributeBinding) -> Any:
264
+ def _unique_value(resource: Resource[Any], attribute: AttributeBinding) -> Any:
249
265
  """Return the value a resource holds for a unique attribute, in the form it is compared in.
250
266
 
251
267
  A resource whose schemas do not declare the attribute holds no value.
252
268
  """
253
- value = Path[type(resource)](attribute.urn).get(resource, strict=False)
269
+ value = parametrize(Path, type(resource))(attribute.urn).get(
270
+ resource, strict=False
271
+ )
254
272
  if isinstance(value, str) and not attribute.case_exact:
255
273
  return value.casefold()
256
274
  return value
257
275
 
258
276
  @staticmethod
259
- def _touch_resource(resource: Resource, last_modified: datetime.datetime):
277
+ def _touch_resource(
278
+ resource: Resource[Any], last_modified: datetime.datetime
279
+ ) -> None:
260
280
  """Touches a resource (updates last_modified and version).
261
281
 
262
282
  Version is generated by hashing last_modified. Another option
263
283
  would be to hash the entire resource instead.
264
284
  """
285
+ assert resource.meta is not None
265
286
  resource.meta.last_modified = last_modified
266
287
  etag = generate_etag(pickle.dumps(resource.meta.last_modified))
267
288
  resource.meta.version = f'W/"{etag}"'
268
289
 
269
290
  def update_resource(
270
- self, resource_type: ResourceType, resource: Resource
271
- ) -> Resource | None:
291
+ self, resource_type: ResourceType, resource: Resource[Any]
292
+ ) -> Resource[Any] | None:
272
293
  found_res_idx = self._get_resource_idx(resource_type, resource.id)
273
294
  if found_res_idx is not None:
274
295
  updated_resource = type(resource).model_validate(resource.model_dump())
@@ -9,11 +9,12 @@ from werkzeug.exceptions import Conflict
9
9
 
10
10
  BULK_ID_PREFIX = "bulkId:"
11
11
 
12
- Resolver = Callable[[BulkOperation], BulkOperation]
12
+ Resolver = Callable[[BulkOperation[Resource[Any]]], BulkOperation[Resource[Any]]]
13
13
  """Replaces the bulkId references of an operation."""
14
14
 
15
15
  OperationRunner = Callable[
16
- [BulkOperation, Resolver], tuple[dict[str, Any], Resource | None]
16
+ [BulkOperation[Resource[Any]], Resolver],
17
+ tuple[dict[str, Any], Resource[Any] | None],
17
18
  ]
18
19
  """Applies an operation once resolved, and returns its outcome and the resource it acted on."""
19
20
 
@@ -50,8 +51,8 @@ def replace_bulk_ids(value: Any, replace: Callable[[str], str]) -> Any:
50
51
 
51
52
 
52
53
  def resolve_operation(
53
- operation: BulkOperation, replace: Callable[[str], str]
54
- ) -> BulkOperation:
54
+ operation: BulkOperation[Resource[Any]], replace: Callable[[str], str]
55
+ ) -> BulkOperation[Resource[Any]]:
55
56
  """Replace the "bulkId:" references of the path and the data of a bulk operation."""
56
57
  updates: dict[str, Any] = {}
57
58
  if operation.path is not None:
@@ -79,7 +80,7 @@ class BulkJob:
79
80
 
80
81
  def __init__(
81
82
  self,
82
- operations: list[BulkOperation],
83
+ operations: list[BulkOperation[Resource[Any]]],
83
84
  fail_on_errors: int | None,
84
85
  run: OperationRunner,
85
86
  ):
@@ -87,7 +88,7 @@ class BulkJob:
87
88
  self.operations = operations
88
89
  self.fail_on_errors = fail_on_errors
89
90
  self.results: dict[int, dict[str, Any]] = {}
90
- self.created: dict[str, Resource] = {}
91
+ self.created: dict[str, Resource[Any]] = {}
91
92
  self.running: set[int] = set()
92
93
  self.errors = 0
93
94
 
@@ -145,7 +146,7 @@ class BulkJob:
145
146
  return bulk_id is not None and self.creations.get(bulk_id) == index
146
147
 
147
148
  @staticmethod
148
- def references(operation: BulkOperation) -> list[str]:
149
+ def references(operation: BulkOperation[Resource[Any]]) -> list[str]:
149
150
  """Return the bulkIds an operation references."""
150
151
  bulk_ids: list[str] = []
151
152
 
@@ -156,7 +157,9 @@ class BulkJob:
156
157
  resolve_operation(operation, collect)
157
158
  return bulk_ids
158
159
 
159
- def resolve(self, index: int, operation: BulkOperation) -> BulkOperation:
160
+ def resolve(
161
+ self, index: int, operation: BulkOperation[Resource[Any]]
162
+ ) -> BulkOperation[Resource[Any]]:
160
163
  """Replace the bulkId references of an operation with the identifiers of the created resources.
161
164
 
162
165
  :raises Conflict: When a referenced resource was not created, as
@@ -2,8 +2,12 @@ import argparse
2
2
  import json
3
3
  import logging
4
4
  import pprint
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING
5
7
 
6
8
  from scim2_models import AuthenticationScheme
9
+ from scim2_models import External
10
+ from scim2_models import Reference
7
11
  from scim2_models import ResourceType
8
12
  from scim2_models import Schema
9
13
  from scim2_models import ScimProvider
@@ -16,25 +20,32 @@ from scim2_server.utils import load_default_resource_types
16
20
  from scim2_server.utils import load_default_schemas
17
21
  from scim2_server.utils import load_default_service_provider_config
18
22
 
23
+ if TYPE_CHECKING:
24
+ from _typeshed.wsgi import StartResponse
25
+ from _typeshed.wsgi import WSGIApplication
26
+ from _typeshed.wsgi import WSGIEnvironment
27
+
19
28
  BEARER_TOKEN_SCHEME = AuthenticationScheme(
20
- type="oauthbearertoken",
29
+ type=AuthenticationScheme.Type.oauthbearertoken,
21
30
  name="bearer_token",
22
31
  description="HTTP Bearer Token",
23
- spec_uri="https://datatracker.ietf.org/doc/html/rfc6750",
32
+ spec_uri=Reference[External]("https://datatracker.ietf.org/doc/html/rfc6750"),
24
33
  )
25
34
 
26
35
 
27
- def log_environ(handler):
36
+ def log_environ(handler: "WSGIApplication") -> "WSGIApplication":
28
37
  """Build a simple decorator to log all WSGI environment variables."""
29
38
 
30
- def _inner(environ, start_fn):
39
+ def _inner(
40
+ environ: "WSGIEnvironment", start_fn: "StartResponse"
41
+ ) -> Iterable[bytes]:
31
42
  logging.getLogger("log_environ").debug(pprint.pformat(environ))
32
43
  return handler(environ, start_fn)
33
44
 
34
45
  return _inner
35
46
 
36
47
 
37
- def main():
48
+ def main() -> None:
38
49
  parser = argparse.ArgumentParser()
39
50
  parser.add_argument(
40
51
  "--schema", type=argparse.FileType("r"), help="Schema definitions"
@@ -71,12 +82,14 @@ def main():
71
82
 
72
83
  from werkzeug.serving import run_simple
73
84
 
85
+ schemas: Iterable[Schema]
74
86
  if args.schema is None:
75
87
  schemas = load_default_schemas().values()
76
88
  else:
77
89
  with args.schema:
78
90
  schemas = [Schema.model_validate(sc) for sc in json.load(args.schema)]
79
91
 
92
+ resource_types: Iterable[ResourceType]
80
93
  if args.resource_type is None:
81
94
  resource_types = load_default_resource_types().values()
82
95
  else:
@@ -108,17 +121,21 @@ def main():
108
121
  for bearer_token in args.bearer_token:
109
122
  app.register_bearer_token(bearer_token)
110
123
 
124
+ wsgi_app: WSGIApplication = app
111
125
  if args.debug:
112
- app = log_environ(app)
126
+ wsgi_app = log_environ(wsgi_app)
113
127
  if args.reverse_proxy:
114
- app = ProxyFix(app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
128
+ wsgi_app = ProxyFix(
129
+ wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
130
+ )
115
131
 
116
132
  run_simple(
117
133
  args.hostname,
118
134
  args.port,
119
- app,
135
+ wsgi_app,
120
136
  use_debugger=args.debug,
121
137
  use_reloader=args.debug,
138
+ threaded=True,
122
139
  )
123
140
 
124
141
  if args.dump_resources:
@@ -1,7 +1,10 @@
1
1
  import itertools
2
2
  import json
3
3
  import logging
4
+ from collections.abc import Iterable
5
+ from typing import TYPE_CHECKING
4
6
  from typing import Any
7
+ from typing import TypeVar
5
8
  from typing import Union
6
9
  from typing import cast
7
10
  from urllib.parse import urljoin
@@ -33,6 +36,7 @@ from werkzeug import Response
33
36
  from werkzeug.datastructures import ETags
34
37
  from werkzeug.exceptions import Forbidden
35
38
  from werkzeug.exceptions import HTTPException
39
+ from werkzeug.exceptions import MethodNotAllowed
36
40
  from werkzeug.exceptions import NotFound
37
41
  from werkzeug.exceptions import NotImplemented as WerkzeugNotImplemented
38
42
  from werkzeug.exceptions import PreconditionFailed
@@ -40,6 +44,7 @@ from werkzeug.exceptions import RequestEntityTooLarge
40
44
  from werkzeug.exceptions import Unauthorized
41
45
  from werkzeug.http import parse_etags
42
46
  from werkzeug.http import unquote_etag
47
+ from werkzeug.routing import BaseConverter
43
48
  from werkzeug.routing import Map
44
49
  from werkzeug.routing import Rule
45
50
  from werkzeug.routing.exceptions import RequestRedirect
@@ -48,6 +53,11 @@ from scim2_server.backend import Backend
48
53
  from scim2_server.bulk import BulkJob
49
54
  from scim2_server.bulk import Resolver
50
55
  from scim2_server.utils import load_default_service_provider_config
56
+ from scim2_server.utils import parametrize
57
+
58
+ if TYPE_CHECKING:
59
+ from _typeshed.wsgi import StartResponse
60
+ from _typeshed.wsgi import WSGIEnvironment
51
61
 
52
62
  SEARCH_REQUEST_PARAMETERS = (
53
63
  "attributes",
@@ -59,6 +69,10 @@ SEARCH_REQUEST_PARAMETERS = (
59
69
  "count",
60
70
  )
61
71
 
72
+ DiscoveryResourceT = TypeVar(
73
+ "DiscoveryResourceT", ResourceType, Schema, ServiceProviderConfig
74
+ )
75
+
62
76
  BULK_SUCCESS_STATUS = {
63
77
  BulkOperation.Method.post: 201,
64
78
  BulkOperation.Method.put: 200,
@@ -67,11 +81,25 @@ BULK_SUCCESS_STATUS = {
67
81
  }
68
82
 
69
83
 
84
+ class ResourceEndpointConverter(BaseConverter):
85
+ """Match a resource endpoint, but not the endpoints RFC 7644 reserves nor the version prefix.
86
+
87
+ A request with a method a reserved endpoint does not support then gets a
88
+ 405 answer, instead of being routed to a resource type of that name.
89
+ """
90
+
91
+ # A reserved name followed by the end of the path segment is refused.
92
+ regex = (
93
+ r"(?!(?:ServiceProviderConfig|ResourceTypes|Schemas|Bulk|Me|v2)(?![^/]))[^/]+"
94
+ )
95
+ part_isolating = True
96
+
97
+
70
98
  class SCIMApplication:
71
99
  """A WSGI application implementing a SCIM provider (server)."""
72
100
 
73
101
  def __init__(self, backend: Backend, provider: ScimProvider):
74
- self.bearer_tokens = set()
102
+ self.bearer_tokens: set[str] = set()
75
103
  self.backend = backend
76
104
  self.provider = provider
77
105
  self.config = provider.config or load_default_service_provider_config()
@@ -111,17 +139,17 @@ class SCIMApplication:
111
139
  methods=("GET", "POST", "PUT", "PATCH", "DELETE"),
112
140
  ),
113
141
  Rule(
114
- f"{prefix}/<string:resource_endpoint>",
142
+ f"{prefix}/<resource_endpoint:resource_endpoint>",
115
143
  endpoint="resource",
116
144
  methods=("GET", "POST"),
117
145
  ),
118
146
  Rule(
119
- f"{prefix}/<string:resource_endpoint>/.search",
147
+ f"{prefix}/<resource_endpoint:resource_endpoint>/.search",
120
148
  endpoint="resource_search",
121
149
  methods=("POST",),
122
150
  ),
123
151
  Rule(
124
- f"{prefix}/<string:resource_endpoint>/<string:resource_id>",
152
+ f"{prefix}/<resource_endpoint:resource_endpoint>/<string:resource_id>",
125
153
  endpoint="single_resource",
126
154
  methods=("GET", "PUT", "PATCH", "DELETE"),
127
155
  ),
@@ -136,13 +164,15 @@ class SCIMApplication:
136
164
  for prefix in ("", "/v2")
137
165
  )
138
166
 
139
- self.url_map = Map(rules)
167
+ self.url_map = Map(
168
+ rules, converters={"resource_endpoint": ResourceEndpointConverter}
169
+ )
140
170
 
141
- def get_model(self, resource_type: ResourceType) -> type[Resource]:
171
+ def get_model(self, resource_type: ResourceType) -> type[Resource[Any]]:
142
172
  """Return the model of a resource type, its extensions included."""
143
- return cast(type[Resource], self.provider.model_for(resource_type))
173
+ return cast(type[Resource[Any]], self.provider.model_for(resource_type))
144
174
 
145
- def get_models(self) -> list[type[Resource]]:
175
+ def get_models(self) -> list[type[Resource[Any]]]:
146
176
  """Return the models of every resource type."""
147
177
  return [self.get_model(rt) for rt in self.provider.resource_types]
148
178
 
@@ -152,7 +182,7 @@ class SCIMApplication:
152
182
  (
153
183
  resource_type
154
184
  for resource_type in self.provider.resource_types
155
- if resource_type.endpoint.lstrip("/").casefold()
185
+ if (resource_type.endpoint or "").lstrip("/").casefold()
156
186
  == endpoint.lstrip("/").casefold()
157
187
  ),
158
188
  None,
@@ -163,12 +193,13 @@ class SCIMApplication:
163
193
  """Whether the configuration declares the resources versioned with ETags."""
164
194
  return bool(self.config.etag and self.config.etag.supported)
165
195
 
166
- def publish(self, request: Request, resource: Resource) -> Resource:
196
+ def publish(self, request: Request, resource: Resource[Any]) -> Resource[Any]:
167
197
  """Return a copy of a resource in the form sent to the client.
168
198
 
169
199
  Its location is made absolute from the URL the client requested, and
170
200
  its version is left out when the service does not support ETags.
171
201
  """
202
+ assert resource.meta is not None
172
203
  update: dict[str, Any] = {
173
204
  "location": urljoin(request.url + "/", resource.meta.location)
174
205
  }
@@ -179,13 +210,14 @@ class SCIMApplication:
179
210
  )
180
211
 
181
212
  @staticmethod
182
- def etag_header(resource: Resource) -> dict[str, str]:
213
+ def etag_header(resource: Resource[Any]) -> dict[str, str]:
183
214
  """Return the ETag header of a published resource, if it has a version."""
215
+ assert resource.meta is not None
184
216
  return {"ETag": resource.meta.version} if resource.meta.version else {}
185
217
 
186
218
  def check_preconditions(
187
219
  self,
188
- resource: Resource,
220
+ resource: Resource[Any],
189
221
  method: str,
190
222
  if_match: ETags | None = None,
191
223
  if_none_match: ETags | None = None,
@@ -199,11 +231,13 @@ class SCIMApplication:
199
231
  :return: :data:`False` when a GET should answer 304 Not Modified.
200
232
  :raises PreconditionFailed: When the method must not be performed.
201
233
  """
234
+ assert resource.meta is not None
202
235
  # A service that does not support ETags has no tag to match: RFC 7232
203
236
  # §3.1 fails an If-Match listing tags, and lets "*" pass.
204
237
  version, _ = (
205
238
  unquote_etag(resource.meta.version) if self.etag_supported else (None, None)
206
239
  )
240
+ version = version or ""
207
241
  # RFC 7232 §3.1 compares If-Match strongly, which would never match
208
242
  # the weak ETags RFC 7644 §3.14 recommends and sends in its example.
209
243
  if if_match and not if_match.contains_weak(version):
@@ -218,7 +252,7 @@ class SCIMApplication:
218
252
 
219
253
  def get_existing_resource(
220
254
  self, resource_type: ResourceType, resource_id: str
221
- ) -> Resource:
255
+ ) -> Resource[Any]:
222
256
  """Return a stored resource.
223
257
 
224
258
  :raises NotFound: When no resource of this type has this identifier.
@@ -228,7 +262,7 @@ class SCIMApplication:
228
262
  raise NotFound
229
263
  return resource
230
264
 
231
- def create(self, resource_type: ResourceType, payload: Any) -> Resource:
265
+ def create(self, resource_type: ResourceType, payload: Any) -> Resource[Any]:
232
266
  """Validate a creation payload and store the new resource."""
233
267
  resource = self.get_model(resource_type).model_validate(
234
268
  payload, scim_ctx=Context.RESOURCE_CREATION_REQUEST
@@ -242,7 +276,7 @@ class SCIMApplication:
242
276
  payload: Any,
243
277
  if_match: ETags | None = None,
244
278
  if_none_match: ETags | None = None,
245
- ) -> Resource:
279
+ ) -> Resource[Any]:
246
280
  """Replace a stored resource with a payload and return the stored result."""
247
281
  resource = self.get_existing_resource(resource_type, resource_id)
248
282
  self.check_preconditions(resource, "PUT", if_match, if_none_match)
@@ -253,7 +287,7 @@ class SCIMApplication:
253
287
  # A PUT that changes nothing keeps meta.lastModified and the ETag.
254
288
  if not replacement.replace(resource):
255
289
  return resource
256
- return self.backend.update_resource(resource_type, replacement)
290
+ return self.update(resource_type, replacement)
257
291
 
258
292
  def patch(
259
293
  self,
@@ -262,17 +296,31 @@ class SCIMApplication:
262
296
  payload: Any,
263
297
  if_match: ETags | None = None,
264
298
  if_none_match: ETags | None = None,
265
- ) -> Resource:
299
+ ) -> Resource[Any]:
266
300
  """Apply a PATCH payload to a stored resource and return the stored result."""
267
301
  self.ensure_supported(self.config.patch, "PATCH")
268
- patch_operation = PatchOp[self.get_model(resource_type)].model_validate(payload)
302
+ patch_operation = parametrize(
303
+ PatchOp, self.get_model(resource_type)
304
+ ).model_validate(payload)
269
305
  resource = self.get_existing_resource(resource_type, resource_id)
270
306
  self.check_preconditions(resource, "PATCH", if_match, if_none_match)
271
307
 
272
308
  # A PATCH that changes nothing keeps meta.lastModified and the ETag.
273
309
  if not patch_operation.patch(resource):
274
310
  return resource
275
- return self.backend.update_resource(resource_type, resource)
311
+ return self.update(resource_type, resource)
312
+
313
+ def update(
314
+ self, resource_type: ResourceType, resource: Resource[Any]
315
+ ) -> Resource[Any]:
316
+ """Store an updated resource and return the stored result.
317
+
318
+ :raises NotFound: When the backend no longer has the resource.
319
+ """
320
+ updated = self.backend.update_resource(resource_type, resource)
321
+ if updated is None:
322
+ raise NotFound
323
+ return updated
276
324
 
277
325
  def delete(
278
326
  self,
@@ -287,7 +335,7 @@ class SCIMApplication:
287
335
  self.backend.delete_resource(resource_type, resource_id)
288
336
 
289
337
  def call_single_resource(
290
- self, request: Request, resource_endpoint: str, resource_id: str, **kwargs
338
+ self, request: Request, resource_endpoint: str, resource_id: str, **kwargs: Any
291
339
  ) -> Response:
292
340
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
293
341
  if not resource_type:
@@ -369,10 +417,10 @@ class SCIMApplication:
369
417
 
370
418
  @staticmethod
371
419
  def get_response_parameters(
372
- request: Request, model: type[Resource]
373
- ) -> ResponseParameters:
420
+ request: Request, model: type[Resource[Any]]
421
+ ) -> ResponseParameters[Any]:
374
422
  """Parse the "attributes" and "excludedAttributes" HTTP request parameters."""
375
- return ResponseParameters[model].model_validate(
423
+ return parametrize(ResponseParameters, model).model_validate(
376
424
  {
377
425
  key: request.args[key]
378
426
  for key in ("attributes", "excludedAttributes")
@@ -381,8 +429,8 @@ class SCIMApplication:
381
429
  )
382
430
 
383
431
  def build_search_request(
384
- self, request: Request, models: list[type[Resource]]
385
- ) -> SearchRequest:
432
+ self, request: Request, models: list[type[Resource[Any]]]
433
+ ) -> SearchRequest[Any]:
386
434
  """Construct a SearchRequest object from a werkzeug request.
387
435
 
388
436
  :param request: werkzeug request
@@ -410,9 +458,10 @@ class SCIMApplication:
410
458
  if parameters & {"sortby", "sortorder"}:
411
459
  self.ensure_supported(self.config.sort, "Sorting")
412
460
 
413
- search_request = SearchRequest[Union[tuple(models)]].model_validate( # noqa: UP007
414
- payload, scim_ctx=Context.SEARCH_REQUEST
415
- )
461
+ search_request = parametrize(
462
+ SearchRequest,
463
+ Union[tuple(models)], # noqa: UP007
464
+ ).model_validate(payload, scim_ctx=Context.SEARCH_REQUEST)
416
465
  search_request.start_index = search_request.start_index or 1
417
466
  max_results = self.config.filter.max_results if self.config.filter else None
418
467
  if max_results is not None and (
@@ -421,7 +470,9 @@ class SCIMApplication:
421
470
  search_request.count = max_results
422
471
  return search_request
423
472
 
424
- def query_resource(self, request: Request, resource: ResourceType | None):
473
+ def query_resource(
474
+ self, request: Request, resource: ResourceType | None
475
+ ) -> ListResponse[Resource[Any]]:
425
476
  models = self.get_models() if resource is None else [self.get_model(resource)]
426
477
  search_request = self.build_search_request(request, models)
427
478
 
@@ -438,7 +489,7 @@ class SCIMApplication:
438
489
  for s in results
439
490
  ]
440
491
 
441
- return ListResponse[Union[tuple(self.get_models())]]( # noqa: UP007
492
+ return parametrize(ListResponse, Union[tuple(self.get_models())])( # noqa: UP007
442
493
  total_results=total_results,
443
494
  items_per_page=len(resources),
444
495
  start_index=search_request.start_index,
@@ -446,7 +497,7 @@ class SCIMApplication:
446
497
  )
447
498
 
448
499
  def call_resource(
449
- self, request: Request, resource_endpoint: str, **kwargs
500
+ self, request: Request, resource_endpoint: str, **kwargs: Any
450
501
  ) -> Response:
451
502
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
452
503
  if not resource_type:
@@ -463,6 +514,7 @@ class SCIMApplication:
463
514
  created_resource = self.publish(
464
515
  request, self.create(resource_type, request.json)
465
516
  )
517
+ assert created_resource.meta is not None
466
518
  return self.make_response(
467
519
  created_resource.model_dump(
468
520
  scim_ctx=Context.RESOURCE_CREATION_RESPONSE
@@ -471,7 +523,7 @@ class SCIMApplication:
471
523
  headers={"Location": created_resource.meta.location},
472
524
  )
473
525
 
474
- def call_query_all(self, request: Request, **kwargs) -> Response:
526
+ def call_query_all(self, request: Request, **kwargs: Any) -> Response:
475
527
  return self.make_response(
476
528
  self.query_resource(request, None).model_dump(
477
529
  scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
@@ -479,7 +531,7 @@ class SCIMApplication:
479
531
  )
480
532
 
481
533
  def call_resource_search(
482
- self, request: Request, resource_endpoint: str, **kwargs
534
+ self, request: Request, resource_endpoint: str, **kwargs: Any
483
535
  ) -> Response:
484
536
  resource_type = self.get_resource_type_by_endpoint(resource_endpoint)
485
537
  if not resource_type:
@@ -493,7 +545,7 @@ class SCIMApplication:
493
545
  @staticmethod
494
546
  def ensure_supported(
495
547
  capability: Patch | Bulk | Filter | Sort | None, operation: str
496
- ):
548
+ ) -> None:
497
549
  """Refuse with a 501 an operation the configuration does not declare supported.
498
550
 
499
551
  RFC 7644 §3.12 answers 501 when the service provider does not support
@@ -502,23 +554,38 @@ class SCIMApplication:
502
554
  if capability is None or not capability.supported:
503
555
  raise WerkzeugNotImplemented(f"{operation} is not supported")
504
556
 
505
- def call_bulk(self, request: Request, **kwargs) -> Response:
557
+ @staticmethod
558
+ def ensure_payload_size(request: Request, max_payload_size: int) -> None:
559
+ """Refuse with a 413 a payload larger than max_payload_size, without reading more than that.
560
+
561
+ :raises RequestEntityTooLarge: When the payload is too large.
562
+ """
563
+ # Werkzeug refuses a Content-Length above the limit, but silently cuts a
564
+ # streamed body at the limit. One extra byte tells a cut body apart.
565
+ request.max_content_length = max_payload_size + 1
566
+ try:
567
+ too_large = len(request.get_data()) > max_payload_size
568
+ except RequestEntityTooLarge:
569
+ too_large = True
570
+
571
+ if too_large:
572
+ raise RequestEntityTooLarge(
573
+ f"The payload exceeds the maxPayloadSize ({max_payload_size} bytes)"
574
+ )
575
+
576
+ def call_bulk(self, request: Request, **kwargs: Any) -> Response:
506
577
  """Implement the /Bulk endpoint (RFC 7644 §3.7)."""
507
578
  self.ensure_supported(self.config.bulk, "Bulk")
508
579
  bulk = cast(Bulk, self.config.bulk)
509
580
 
510
- if (
511
- bulk.max_payload_size is not None
512
- and len(request.get_data()) > bulk.max_payload_size
513
- ):
514
- raise RequestEntityTooLarge(
515
- f"The payload exceeds the maxPayloadSize ({bulk.max_payload_size} bytes)"
516
- )
581
+ if bulk.max_payload_size is not None:
582
+ self.ensure_payload_size(request, bulk.max_payload_size)
517
583
 
518
- bulk_request = BulkRequest[Union[tuple(self.get_models())]].model_validate( # noqa: UP007
519
- request.json, scim_ctx=Context.BULK_REQUEST
520
- )
521
- operations = cast(list[BulkOperation], bulk_request.operations)
584
+ bulk_request = parametrize(
585
+ BulkRequest,
586
+ Union[tuple(self.get_models())], # noqa: UP007
587
+ ).model_validate(request.json, scim_ctx=Context.BULK_REQUEST)
588
+ operations = cast(list[BulkOperation[Resource[Any]]], bulk_request.operations)
522
589
  if bulk.max_operations is not None and len(operations) > bulk.max_operations:
523
590
  raise RequestEntityTooLarge(
524
591
  f"The number of operations exceeds the maxOperations ({bulk.max_operations})"
@@ -532,14 +599,17 @@ class SCIMApplication:
532
599
  ),
533
600
  ).run()
534
601
  return self.make_response(
535
- BulkResponse[Union[tuple(self.get_models())]]( # noqa: UP007
536
- operations=results
537
- ).model_dump(scim_ctx=Context.BULK_RESPONSE)
602
+ parametrize(BulkResponse, Union[tuple(self.get_models())]) # noqa: UP007
603
+ .model_validate({"operations": results})
604
+ .model_dump(scim_ctx=Context.BULK_RESPONSE)
538
605
  )
539
606
 
540
607
  def run_bulk_operation(
541
- self, request: Request, operation: BulkOperation, resolve: Resolver
542
- ) -> tuple[dict[str, Any], Resource | None]:
608
+ self,
609
+ request: Request,
610
+ operation: BulkOperation[Resource[Any]],
611
+ resolve: Resolver,
612
+ ) -> tuple[dict[str, Any], Resource[Any] | None]:
543
613
  """Apply one operation of a bulk job.
544
614
 
545
615
  An operation that failed its validation keeps its error, once its
@@ -556,6 +626,7 @@ class SCIMApplication:
556
626
  operation = resolve(operation)
557
627
  resource_type = self.get_resource_type_by_endpoint(operation.endpoint or "")
558
628
  if resource_type is not None and operation.resource_id:
629
+ assert resource_type.endpoint is not None
559
630
  result["location"] = urljoin(
560
631
  request.url,
561
632
  f"{resource_type.endpoint.strip('/')}/{operation.resource_id}",
@@ -573,18 +644,20 @@ class SCIMApplication:
573
644
  error = self.error_from(exception)
574
645
  return {**result, "status": error.status, "response": error}, None
575
646
 
647
+ assert operation.method is not None
576
648
  result["status"] = BULK_SUCCESS_STATUS[operation.method]
577
649
  if resource is None:
578
650
  return result, None
579
651
 
580
652
  resource = self.publish(request, resource)
653
+ assert resource.meta is not None
581
654
  result["location"] = resource.meta.location
582
655
  result["version"] = resource.meta.version
583
656
  return result, resource
584
657
 
585
658
  def apply_bulk_operation(
586
- self, resource_type: ResourceType, operation: BulkOperation
587
- ) -> Resource | None:
659
+ self, resource_type: ResourceType, operation: BulkOperation[Resource[Any]]
660
+ ) -> Resource[Any] | None:
588
661
  """Apply a validated bulk operation, and return the resource it acted on.
589
662
 
590
663
  The data of the operation is already validated, and the resource
@@ -596,10 +669,11 @@ class SCIMApplication:
596
669
  detail="A POST path must target a resource type endpoint, other methods a resource"
597
670
  )
598
671
 
672
+ if operation.method == BulkOperation.Method.post or resource_id is None:
673
+ return self.create(resource_type, operation.data)
674
+
599
675
  if_match = parse_etags(operation.version) if operation.version else None
600
676
  match operation.method:
601
- case BulkOperation.Method.post:
602
- return self.create(resource_type, operation.data)
603
677
  case BulkOperation.Method.put:
604
678
  return self.replace(
605
679
  resource_type, resource_id, operation.data, if_match
@@ -610,7 +684,7 @@ class SCIMApplication:
610
684
  self.delete(resource_type, resource_id, if_match)
611
685
  return None
612
686
 
613
- def call_me(self, request: Request, **kwargs):
687
+ def call_me(self, request: Request, **kwargs: Any) -> Response:
614
688
  """Implement the /Me endpoint.
615
689
 
616
690
  RFC 7644, Section 3.11 allows raising a 501 (Not Implemented) if
@@ -618,14 +692,14 @@ class SCIMApplication:
618
692
  """
619
693
  raise WerkzeugNotImplemented
620
694
 
621
- def register_bearer_token(self, token: str):
695
+ def register_bearer_token(self, token: str) -> None:
622
696
  """Register a static bearer token for authentication.
623
697
 
624
698
  :param token: Bearer token
625
699
  """
626
700
  self.bearer_tokens.add(token)
627
701
 
628
- def check_auth(self, request: Request):
702
+ def check_auth(self, request: Request) -> None:
629
703
  """Check the authorization headers."""
630
704
  if not self.bearer_tokens:
631
705
  return
@@ -636,7 +710,7 @@ class SCIMApplication:
636
710
  raise Unauthorized
637
711
 
638
712
  @staticmethod
639
- def make_response(content, status=200, **kwargs) -> Response:
713
+ def make_response(content: Any, status: int = 200, **kwargs: Any) -> Response:
640
714
  """Construct a werkzeug response from any JSON-serializable content."""
641
715
  etag = None
642
716
  if content is not None:
@@ -667,17 +741,17 @@ class SCIMApplication:
667
741
  case _:
668
742
  return Error(status=500, detail="Internal server error")
669
743
 
670
- def make_error(self, error: Error):
744
+ def make_error(self, error: Error) -> Response:
671
745
  """Construct a werkzeug response from a SCIM Error."""
672
- return self.make_response(error.model_dump(), status=int(error.status))
746
+ return self.make_response(error.model_dump(), status=int(error.status or 500))
673
747
 
674
748
  @staticmethod
675
- def forbid_filter(request: Request):
749
+ def forbid_filter(request: Request) -> None:
676
750
  """RFC 7644, Section 4: "If a "filter" is provided, the service provider SHOULD respond with HTTP status code 403 (Forbidden)"."""
677
751
  if "filter" in request.args:
678
752
  raise Forbidden
679
753
 
680
- def call_service_provider_config(self, request: Request, **kwargs):
754
+ def call_service_provider_config(self, request: Request, **kwargs: Any) -> Response:
681
755
  """Return the ServiceProviderConfig."""
682
756
  self.forbid_filter(request)
683
757
  return self.make_response(
@@ -685,12 +759,14 @@ class SCIMApplication:
685
759
  )
686
760
 
687
761
  @staticmethod
688
- def locate(resource: ResourceType | Schema | ServiceProviderConfig, location: str):
762
+ def locate(resource: DiscoveryResourceT, location: str) -> DiscoveryResourceT:
689
763
  """Return a copy of a discovery resource carrying its meta."""
690
764
  meta = Meta(resource_type=type(resource).__name__, location=location)
691
765
  return resource.model_copy(update={"meta": meta})
692
766
 
693
- def call_resource_type(self, request: Request, resource_type: str, **kwargs):
767
+ def call_resource_type(
768
+ self, request: Request, resource_type: str, **kwargs: Any
769
+ ) -> Response:
694
770
  """Return a single resource type."""
695
771
  self.forbid_filter(request)
696
772
  for res in self.provider.resource_types:
@@ -700,7 +776,7 @@ class SCIMApplication:
700
776
  )
701
777
  raise NotFound
702
778
 
703
- def call_schema(self, request: Request, schema_id: str):
779
+ def call_schema(self, request: Request, schema_id: str) -> Response:
704
780
  """Return a single schema."""
705
781
  self.forbid_filter(request)
706
782
  for res in self.provider.schemas:
@@ -710,7 +786,7 @@ class SCIMApplication:
710
786
  )
711
787
  raise NotFound
712
788
 
713
- def call_resource_types(self, request: Request, **kwargs):
789
+ def call_resource_types(self, request: Request, **kwargs: Any) -> Response:
714
790
  """Return a ListResponse of all known resource types."""
715
791
  self.forbid_filter(request)
716
792
  results = self.provider.resource_types
@@ -722,7 +798,7 @@ class SCIMApplication:
722
798
  ).model_dump()
723
799
  return self.make_response(resp)
724
800
 
725
- def call_schemas(self, request: Request, **kwargs):
801
+ def call_schemas(self, request: Request, **kwargs: Any) -> Response:
726
802
  """Return a ListResponse of all known schemas."""
727
803
  self.forbid_filter(request)
728
804
  results = self.provider.schemas
@@ -734,7 +810,7 @@ class SCIMApplication:
734
810
  ).model_dump()
735
811
  return self.make_response(resp)
736
812
 
737
- def wsgi_app(self, request: Request, environ):
813
+ def wsgi_app(self, request: Request, environ: "WSGIEnvironment") -> Response:
738
814
  try:
739
815
  urls = self.url_map.bind_to_environ(environ)
740
816
  endpoint, args = urls.match()
@@ -746,16 +822,22 @@ class SCIMApplication:
746
822
  # Wrap the entire call in a transaction. Should probably be optimized (use transaction only when necessary).
747
823
  # The provider makes its policy the one every payload is read under.
748
824
  with self.provider, self.backend:
749
- response = getattr(self, f"call_{endpoint}")(request, **args)
825
+ response: Response = getattr(self, f"call_{endpoint}")(request, **args)
750
826
  return response
751
827
  except RequestRedirect as e:
752
828
  # urls.match may cause a redirect, handle it as a special case of HTTPException
753
829
  self.log.exception(e)
754
830
  return e.get_response(environ)
755
831
  except Exception as e:
756
- return self.make_error(self.error_from(e))
832
+ response = self.make_error(self.error_from(e))
833
+ if isinstance(e, MethodNotAllowed) and e.valid_methods:
834
+ # RFC 9110 §15.5.6: a 405 answer lists the supported methods.
835
+ response.headers["Allow"] = ", ".join(sorted(e.valid_methods))
836
+ return response
757
837
 
758
- def __call__(self, environ, start_response):
838
+ def __call__(
839
+ self, environ: "WSGIEnvironment", start_response: "StartResponse"
840
+ ) -> Iterable[bytes]:
759
841
  """Return the actual WSGI server implementation."""
760
842
  if environ.get("PATH_INFO", "").endswith(".scim"):
761
843
  # RFC 7644, Section 3.8
File without changes
@@ -1,6 +1,8 @@
1
1
  import importlib.resources
2
2
  import json
3
3
  from typing import Any
4
+ from typing import TypeVar
5
+ from typing import cast
4
6
 
5
7
  from scim2_models import Resource
6
8
  from scim2_models import ResourceType
@@ -8,20 +10,29 @@ from scim2_models import Schema
8
10
  from scim2_models import ScimProvider
9
11
  from scim2_models import ServiceProviderConfig
10
12
 
13
+ GenericT = TypeVar("GenericT")
14
+ ResourceT = TypeVar("ResourceT", bound=Resource[Any])
15
+
16
+
17
+ def parametrize(generic: type[GenericT], parameter: Any) -> type[GenericT]:
18
+ """Parametrize a generic class with a type only known at runtime."""
19
+ return cast("type[GenericT]", cast(Any, generic)[parameter])
20
+
11
21
 
12
22
  def load_json_resource(json_name: str) -> Any:
13
23
  """Load a JSON document from the scim2_server package resources."""
14
24
  fp = importlib.resources.files("scim2_server") / "resources" / json_name
15
- with open(fp) as f:
25
+ with fp.open() as f:
16
26
  return json.load(f)
17
27
 
18
28
 
19
- def load_scim_resource(json_name: str, type_: type[Resource]):
29
+ def load_scim_resource(json_name: str, type_: type[ResourceT]) -> dict[str, ResourceT]:
20
30
  """Load and validates a JSON document from the scim2_server package resources."""
21
31
  ret = {}
22
32
  definitions = load_json_resource(json_name)
23
33
  for d in definitions:
24
34
  model = type_.model_validate(d)
35
+ assert model.id is not None
25
36
  ret[model.id] = model
26
37
  return ret
27
38
 
File without changes
File without changes