scim2-server 0.1.8__tar.gz → 0.2.0__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.1.8
3
+ Version: 0.2.0
4
4
  Summary: Lightweight SCIM2 server prototype
5
5
  Keywords: scim,scim2,provisioning,rfc7643,rfc7644
6
6
  Author: Yaal Coop, Christian Friedrich Coors
@@ -222,8 +222,8 @@ Requires-Dist: scim2-filter-parser>=0.7.0
222
222
  Requires-Dist: scim2-models>=0.6.1
223
223
  Requires-Dist: werkzeug>=3.0.3
224
224
  Requires-Python: >=3.10
225
- Project-URL: funding, https://github.com/sponsors/python-scim
226
225
  Project-URL: repository, https://github.com/python-scim/scim2-server
226
+ Project-URL: funding, https://github.com/sponsors/python-scim
227
227
  Description-Content-Type: text/markdown
228
228
 
229
229
  # scim2-server
@@ -247,7 +247,7 @@ The only optional feature currently missing is support for Bulk operations ([RFC
247
247
  ## Usage
248
248
 
249
249
  ```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]
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]
251
251
  ```
252
252
 
253
253
  - `-h`/`--help`: Show help message
@@ -258,6 +258,18 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-
258
258
  - `--hostname`: The hostname to listen on. Defaults to `127.0.0.1`.
259
259
  - `--port`: The port to listen on. Defaults to `8080`.
260
260
  - `--dump-resources`: Dump a JSON document containing all resources when the provider exits normally.
261
+ - `--debug`: Enable the interactive Werkzeug debugger, the reloader and the logging of the WSGI environment of each request. The debugger allows arbitrary code execution and the environment contains the bearer tokens, so never use this option on a server reachable by others.
262
+
263
+ ### Container
264
+
265
+ A container image is published on the GitHub container registry for each release.
266
+ The server listens on `0.0.0.0:8080` inside the container, and the command line arguments are passed to `scim2-server`:
267
+
268
+ ```shell
269
+ $ docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
270
+ ```
271
+
272
+ To build the image yourself, use `docker build --file Containerfile .` or `podman build .`.
261
273
 
262
274
  ## Notes
263
275
 
@@ -19,7 +19,7 @@ The only optional feature currently missing is support for Bulk operations ([RFC
19
19
  ## Usage
20
20
 
21
21
  ```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]
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]
23
23
  ```
24
24
 
25
25
  - `-h`/`--help`: Show help message
@@ -30,6 +30,18 @@ $ scim2-server [-h] [--schema SCHEMA] [--resource-type RESOURCE_TYPE] [--bearer-
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.
33
+ - `--debug`: Enable the interactive Werkzeug debugger, the reloader and the logging of the WSGI environment of each request. The debugger allows arbitrary code execution and the environment contains the bearer tokens, so never use this option on a server reachable by others.
34
+
35
+ ### Container
36
+
37
+ A container image is published on the GitHub container registry for each release.
38
+ The server listens on `0.0.0.0:8080` inside the container, and the command line arguments are passed to `scim2-server`:
39
+
40
+ ```shell
41
+ $ docker run --publish 8080:8080 ghcr.io/python-scim/scim2-server --bearer-token secret
42
+ ```
43
+
44
+ To build the image yourself, use `docker build --file Containerfile .` or `podman build .`.
33
45
 
34
46
  ## Notes
35
47
 
@@ -0,0 +1,174 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.16,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "scim2-server"
7
+ version = "0.2.0"
8
+ description = "Lightweight SCIM2 server prototype"
9
+ readme = "README.md"
10
+ keywords = [
11
+ "scim",
12
+ "scim2",
13
+ "provisioning",
14
+ "rfc7643",
15
+ "rfc7644",
16
+ ]
17
+ classifiers = [
18
+ "Intended Audience :: Developers",
19
+ "Development Status :: 4 - Beta",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: Python :: Implementation :: CPython",
26
+ "License :: OSI Approved :: Apache Software License",
27
+ "Environment :: Web Environment",
28
+ "Programming Language :: Python",
29
+ "Operating System :: OS Independent",
30
+ ]
31
+ requires-python = ">= 3.10"
32
+ dependencies = [
33
+ "scim2-filter-parser>=0.7.0",
34
+ "scim2-models>=0.6.1",
35
+ "werkzeug>=3.0.3",
36
+ ]
37
+
38
+ [[project.authors]]
39
+ name = "Yaal Coop"
40
+ email = "contact@yaal.coop"
41
+
42
+ [[project.authors]]
43
+ name = "Christian Friedrich Coors"
44
+ email = "me@ccoors.de"
45
+
46
+ [project.license]
47
+ file = "LICENSE"
48
+
49
+ [project.urls]
50
+ repository = "https://github.com/python-scim/scim2-server"
51
+ funding = "https://github.com/sponsors/python-scim"
52
+
53
+ [project.scripts]
54
+ scim2-server = "scim2_server.cli:main"
55
+
56
+ [dependency-groups]
57
+ dev = [
58
+ "httpx>=0.27.0",
59
+ "pytest>=8.2.2",
60
+ "pytest-coverage>=0.0",
61
+ "prek>=0.1.0",
62
+ "time-machine>=2.14.2",
63
+ "tox-uv>=1.16.0",
64
+ ]
65
+
66
+ [tool.uv.build-backend]
67
+ module-root = ""
68
+
69
+ [tool.coverage.run]
70
+ source = [
71
+ "scim2_server",
72
+ "tests",
73
+ ]
74
+ omit = [
75
+ ".tox/*",
76
+ "examples/*",
77
+ "scim2_server/cli.py",
78
+ ]
79
+ branch = true
80
+
81
+ [tool.coverage.report]
82
+ exclude_lines = [
83
+ "@pytest.mark.skip",
84
+ "@pytest.mark.xfail",
85
+ "pragma: no cover",
86
+ "raise NotImplementedError",
87
+ '\.\.\.\s*$',
88
+ ]
89
+
90
+ [tool.ruff.lint]
91
+ select = [
92
+ "B",
93
+ "D",
94
+ "E",
95
+ "F",
96
+ "I",
97
+ "UP",
98
+ ]
99
+ ignore = [
100
+ "E501",
101
+ "E722",
102
+ "D100",
103
+ "D101",
104
+ "D102",
105
+ "D103",
106
+ "D104",
107
+ "D105",
108
+ "D106",
109
+ "D107",
110
+ "D203",
111
+ "D213",
112
+ ]
113
+
114
+ [tool.ruff.lint.isort]
115
+ force-single-line = true
116
+
117
+ [tool.ruff.format]
118
+ docstring-code-format = true
119
+
120
+ [tool.pytest.ini_options]
121
+ addopts = "--doctest-modules --doctest-glob='*.rst'"
122
+ doctest_optionflags = "ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL ELLIPSIS"
123
+
124
+ [tool.tox]
125
+ requires = ["tox>=4.19"]
126
+ env_list = [
127
+ "style",
128
+ "py310",
129
+ "py311",
130
+ "py312",
131
+ "py313",
132
+ "py314",
133
+ "minversions",
134
+ "coverage",
135
+ ]
136
+
137
+ [tool.tox.env_run_base]
138
+ runner = "uv-venv-lock-runner"
139
+ dependency_groups = ["dev"]
140
+ commands = [[
141
+ "pytest",
142
+ "--showlocals",
143
+ "--full-trace",
144
+ "{posargs}",
145
+ ]]
146
+
147
+ [tool.tox.env.style]
148
+ runner = "uv-venv-runner"
149
+ commands = [[
150
+ "prek",
151
+ "run",
152
+ "--all-files",
153
+ "--show-diff-on-failure",
154
+ ]]
155
+
156
+ [tool.tox.env.minversions]
157
+ runner = "uv-venv-runner"
158
+ uv_resolution = "lowest-direct"
159
+
160
+ [tool.tox.env.coverage]
161
+ commands = [
162
+ [
163
+ "pytest",
164
+ "--cov",
165
+ "--cov-fail-under=100",
166
+ "--cov-report",
167
+ "term:skip-covered",
168
+ "{posargs}",
169
+ ],
170
+ [
171
+ "coverage",
172
+ "html",
173
+ ],
174
+ ]
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.8.9,<0.9.0"]
2
+ requires = ["uv_build>=0.12.16,<0.13.0"]
3
3
  build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-server"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "Lightweight SCIM2 server prototype"
9
9
  authors = [
10
10
  {name="Yaal Coop", email="contact@yaal.coop"},
@@ -11,7 +11,6 @@ from scim2_filter_parser.parser import SCIMParser
11
11
  from scim2_models import Attribute
12
12
  from scim2_models import BaseModel
13
13
  from scim2_models import CaseExact
14
- from scim2_models import Error
15
14
  from scim2_models import Extension
16
15
  from scim2_models import Meta
17
16
  from scim2_models import Resource
@@ -19,11 +18,11 @@ from scim2_models import ResourceType
19
18
  from scim2_models import Schema
20
19
  from scim2_models import SearchRequest
21
20
  from scim2_models import Uniqueness
21
+ from scim2_models import UniquenessException
22
22
  from werkzeug.http import generate_etag
23
23
 
24
24
  from scim2_server.filter import evaluate_filter
25
25
  from scim2_server.operators import ResolveSortOperator
26
- from scim2_server.utils import SCIMException
27
26
  from scim2_server.utils import get_by_alias
28
27
 
29
28
 
@@ -120,10 +119,9 @@ class Backend:
120
119
  Resources. The List must contain a copy of resources.
121
120
  Mutating elements in the List must not modify the data
122
121
  stored in the backend.
123
- :raises SCIMException: If the backend only supports querying for
124
- one resource type at a time, setting resource_type_id to
125
- None the backend may raise a
126
- SCIMException(Error.make_too_many_error()).
122
+ :raises TooManyException: If the backend only supports querying
123
+ for one resource type at a time, setting resource_type_id to
124
+ None the backend may raise TooManyException.
127
125
  """
128
126
  raise NotImplementedError
129
127
 
@@ -353,7 +351,7 @@ class InMemoryBackend(Backend):
353
351
  if existing_resource.meta.resource_type == resource_type_id:
354
352
  existing_value = unique_attribute.get_attribute(existing_resource)
355
353
  if existing_value == new_value:
356
- raise SCIMException(Error.make_uniqueness_error())
354
+ raise UniquenessException()
357
355
 
358
356
  self.resources.append(resource)
359
357
  return resource
@@ -392,7 +390,7 @@ class InMemoryBackend(Backend):
392
390
  existing_resource
393
391
  )
394
392
  if existing_value == new_value:
395
- raise SCIMException(Error.make_uniqueness_error())
393
+ raise UniquenessException()
396
394
 
397
395
  self.resources[found_res_idx] = updated_resource
398
396
  return updated_resource
@@ -44,9 +44,14 @@ def main():
44
44
  type=argparse.FileType("w"),
45
45
  help="Dump resources to a JSON file on exit",
46
46
  )
47
+ parser.add_argument(
48
+ "--debug",
49
+ action="store_true",
50
+ help="Enable the interactive debugger, the reloader and the logging of the WSGI environment",
51
+ )
47
52
  args = parser.parse_args()
48
53
 
49
- logging.basicConfig(level=logging.DEBUG)
54
+ logging.basicConfig(level=logging.DEBUG if args.debug else logging.INFO)
50
55
 
51
56
  from werkzeug.serving import run_simple
52
57
 
@@ -77,7 +82,8 @@ def main():
77
82
  for bearer_token in args.bearer_token:
78
83
  app.register_bearer_token(bearer_token)
79
84
 
80
- app = log_environ(app)
85
+ if args.debug:
86
+ app = log_environ(app)
81
87
  if args.reverse_proxy:
82
88
  app = ProxyFix(app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1)
83
89
 
@@ -85,8 +91,8 @@ def main():
85
91
  args.hostname,
86
92
  args.port,
87
93
  app,
88
- use_debugger=True,
89
- use_reloader=True,
94
+ use_debugger=args.debug,
95
+ use_reloader=args.debug,
90
96
  )
91
97
 
92
98
  if args.dump_resources:
@@ -3,9 +3,8 @@ from types import NoneType
3
3
  from scim2_filter_parser import ast as scim2ast
4
4
  from scim2_models import BaseModel
5
5
  from scim2_models import CaseExact
6
- from scim2_models import Error
6
+ from scim2_models import InvalidFilterException
7
7
 
8
- from scim2_server.utils import SCIMException
9
8
  from scim2_server.utils import get_by_alias
10
9
  from scim2_server.utils import parse_new_value
11
10
 
@@ -125,4 +124,4 @@ def check_comparable_value(value):
125
124
  status code 400) with "scimType" of "invalidFilter"."
126
125
  """
127
126
  if isinstance(value, bytes | bool | NoneType):
128
- raise SCIMException(Error.make_invalid_filter_error())
127
+ raise InvalidFilterException()
@@ -5,15 +5,18 @@ from scim2_filter_parser.lexer import SCIMLexer
5
5
  from scim2_filter_parser.parser import SCIMParser
6
6
  from scim2_models import BaseModel
7
7
  from scim2_models import CaseExact
8
- from scim2_models import Error
8
+ from scim2_models import InvalidPathException
9
+ from scim2_models import InvalidValueException
9
10
  from scim2_models import Mutability
11
+ from scim2_models import MutabilityException
12
+ from scim2_models import NoTargetException
10
13
  from scim2_models import PatchOperation
11
14
  from scim2_models import Required
12
15
  from scim2_models import Resource
13
16
  from scim2_models import Returned
17
+ from scim2_models import SensitiveException
14
18
 
15
19
  from scim2_server.filter import evaluate_filter
16
- from scim2_server.utils import SCIMException
17
20
  from scim2_server.utils import get_by_alias
18
21
  from scim2_server.utils import get_or_create
19
22
  from scim2_server.utils import handle_extension
@@ -55,7 +58,7 @@ def parse_attribute_path(attribute_path: str | None) -> dict[str, Any] | None:
55
58
 
56
59
  match = ATTRIBUTE_PATH_REGEX.match(attribute_path)
57
60
  if not match:
58
- raise SCIMException(Error.make_invalid_path_error())
61
+ raise InvalidPathException()
59
62
  parse_attribute_path.cache[attribute_path] = match.groupdict()
60
63
  return match.groupdict()
61
64
 
@@ -148,7 +151,7 @@ class Operator:
148
151
  attribute_name = get_by_alias(type(model), attribute)
149
152
  multi_valued_attribute = get_or_create(model, attribute_name, True)
150
153
  if not isinstance(multi_valued_attribute, list):
151
- raise SCIMException(Error.make_invalid_path_error())
154
+ raise InvalidPathException()
152
155
  token_stream = SCIMLexer().tokenize(condition)
153
156
  condition = SCIMParser().parse(token_stream)
154
157
  self.init_return(model, attribute_name, sub_attribute, self.value)
@@ -160,13 +163,13 @@ class Operator:
160
163
  self, attribute: str, condition: str, model: BaseModel
161
164
  ):
162
165
  if self.REQUIRES_VALUE and not isinstance(self.value, dict):
163
- raise SCIMException(Error.make_invalid_value_error())
166
+ raise InvalidValueException()
164
167
  attribute_name = get_by_alias(type(model), attribute)
165
168
  multi_valued_attribute = get_or_create(
166
169
  model, attribute_name, self.REQUIRES_VALUE
167
170
  )
168
171
  if not isinstance(multi_valued_attribute, list):
169
- raise SCIMException(Error.make_invalid_path_error())
172
+ raise InvalidPathException()
170
173
  token_stream = SCIMLexer().tokenize(condition)
171
174
  condition = SCIMParser().parse(token_stream)
172
175
  if self.REQUIRES_VALUE:
@@ -196,7 +199,7 @@ class Operator:
196
199
  self.match_attribute(sub_path, value)
197
200
  else:
198
201
  if not isinstance(complex_attribute, BaseModel):
199
- raise SCIMException(Error.make_invalid_path_error())
202
+ raise InvalidPathException()
200
203
  self.match_attribute(sub_path, complex_attribute)
201
204
 
202
205
  def match_attribute(self, attribute: str, model: BaseModel):
@@ -205,9 +208,9 @@ class Operator:
205
208
 
206
209
  def call_on_root(self, model: Resource):
207
210
  if not self.OPERATE_ON_ROOT:
208
- raise SCIMException(Error.make_no_target_error())
211
+ raise NoTargetException()
209
212
  if not isinstance(self.value, dict):
210
- raise SCIMException(Error.make_invalid_value_error())
213
+ raise InvalidValueException()
211
214
  for k, v in self.value.items():
212
215
  ext, scim_name = handle_extension(model, k)
213
216
  if ext == model:
@@ -233,7 +236,7 @@ class AddOperator(Operator):
233
236
  return
234
237
 
235
238
  if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
236
- raise SCIMException(Error.make_mutability_error())
239
+ raise MutabilityException()
237
240
 
238
241
  if model.get_field_multiplicity(alias):
239
242
  if getattr(model, alias) is None:
@@ -247,7 +250,7 @@ class AddOperator(Operator):
247
250
  model.get_field_annotation(alias, Required) == Required.true
248
251
  and not new_value
249
252
  ):
250
- raise SCIMException(Error.make_invalid_value_error())
253
+ raise InvalidValueException()
251
254
  setattr(model, alias, new_value)
252
255
 
253
256
 
@@ -268,10 +271,10 @@ class RemoveOperator(Operator):
268
271
  Mutability.read_only,
269
272
  Mutability.immutable,
270
273
  ):
271
- raise SCIMException(Error.make_mutability_error())
274
+ raise MutabilityException()
272
275
 
273
276
  if model.get_field_annotation(alias, Required) == Required.true:
274
- raise SCIMException(Error.make_invalid_value_error())
277
+ raise InvalidValueException()
275
278
 
276
279
  setattr(model, alias, None)
277
280
 
@@ -283,7 +286,7 @@ class ReplaceOperator(Operator):
283
286
  def operation(cls, model: BaseModel, attribute: str, value: Any):
284
287
  alias = get_by_alias(type(model), attribute)
285
288
  if model.get_field_multiplicity(alias) and not isinstance(value, list):
286
- raise SCIMException(Error.make_invalid_value_error())
289
+ raise InvalidValueException()
287
290
 
288
291
  existing_value = getattr(model, alias)
289
292
  new_value = parse_new_value(model, alias, value)
@@ -291,13 +294,13 @@ class ReplaceOperator(Operator):
291
294
  return
292
295
 
293
296
  if model.get_field_annotation(alias, Mutability) == Mutability.read_only:
294
- raise SCIMException(Error.make_mutability_error())
297
+ raise MutabilityException()
295
298
 
296
299
  if (
297
300
  model.get_field_annotation(alias, Required) == Required.true
298
301
  and not new_value
299
302
  ):
300
- raise SCIMException(Error.make_invalid_value_error())
303
+ raise InvalidValueException()
301
304
  setattr(model, alias, new_value)
302
305
 
303
306
 
@@ -372,7 +375,7 @@ class ResolveOperator(Operator):
372
375
  model.get_field_annotation(alias, Mutability) == Mutability.write_only
373
376
  or model.get_field_annotation(alias, Returned) == Returned.never
374
377
  ):
375
- raise SCIMException(Error.make_sensitive_error())
378
+ raise SensitiveException()
376
379
 
377
380
  @classmethod
378
381
  def operation(
@@ -20,6 +20,7 @@ from scim2_models import PatchOp
20
20
  from scim2_models import Resource
21
21
  from scim2_models import ResourceType
22
22
  from scim2_models import Schema
23
+ from scim2_models import SCIMException
23
24
  from scim2_models import SearchRequest
24
25
  from scim2_models import ServiceProviderConfig
25
26
  from scim2_models import Sort
@@ -39,7 +40,6 @@ from werkzeug.routing.exceptions import RequestRedirect
39
40
 
40
41
  from scim2_server.backend import Backend
41
42
  from scim2_server.operators import patch_resource
42
- from scim2_server.utils import SCIMException
43
43
  from scim2_server.utils import merge_resources
44
44
 
45
45
 
@@ -537,7 +537,7 @@ class SCIMProvider:
537
537
  return self.make_error(Error(status=e.code, detail=e.description))
538
538
  except SCIMException as e:
539
539
  self.log.exception(e)
540
- return self.make_error(e.scim_error)
540
+ return self.make_error(e.to_error())
541
541
  except ValidationError as e:
542
542
  self.log.exception(e)
543
543
  return self.make_error(Error(status=400, detail=str(e)))
@@ -8,21 +8,16 @@ from typing import Any
8
8
  from pydantic import EmailStr
9
9
  from pydantic import ValidationError
10
10
  from scim2_models import BaseModel
11
- from scim2_models import Error
12
11
  from scim2_models import Extension
12
+ from scim2_models import InvalidValueException
13
13
  from scim2_models import Mutability
14
+ from scim2_models import MutabilityException
15
+ from scim2_models import NoTargetException
14
16
  from scim2_models import Resource
15
17
  from scim2_models import ResourceType
16
18
  from scim2_models import Schema
17
19
 
18
20
 
19
- class SCIMException(Exception):
20
- """A wrapper class, because an "Error" does not inherit from Exception and should not be raised."""
21
-
22
- def __init__(self, scim_error: Error):
23
- self.scim_error = scim_error
24
-
25
-
26
21
  def load_json_resource(json_name: str) -> list:
27
22
  """Load a JSON document from the scim2_server package resources."""
28
23
  fp = importlib.resources.files("scim2_server") / "resources" / json_name
@@ -69,7 +64,7 @@ def merge_resources(target: Resource, updates: BaseModel):
69
64
  if mutability == Mutability.immutable and getattr(
70
65
  target, set_attribute
71
66
  ) not in (None, new_value):
72
- raise SCIMException(Error.make_mutability_error())
67
+ raise MutabilityException()
73
68
  setattr(target, set_attribute, new_value)
74
69
 
75
70
 
@@ -82,8 +77,8 @@ def get_by_alias(
82
77
  :param scim_name: SCIM attribute name
83
78
  :param allow_none: Allow returning None if attribute is not found
84
79
  :return: pydantic attribute name
85
- :raises SCIMException: If no attribute is found and allow_none is
86
- False
80
+ :raises NoTargetException: If no attribute is found and allow_none
81
+ is False
87
82
  """
88
83
  try:
89
84
  return next(
@@ -94,7 +89,7 @@ def get_by_alias(
94
89
  except StopIteration as e:
95
90
  if allow_none:
96
91
  return None
97
- raise SCIMException(Error.make_no_target_error()) from e
92
+ raise NoTargetException() from e
98
93
 
99
94
 
100
95
  def get_or_create(
@@ -107,7 +102,7 @@ def get_or_create(
107
102
  :param check_mutability: If True, validate that the attribute is
108
103
  mutable
109
104
  :return: A complex attribute model
110
- :raises SCIMException: If attribute is not mutable and
105
+ :raises MutabilityException: If attribute is not mutable and
111
106
  check_mutability is True
112
107
  """
113
108
  if check_mutability:
@@ -115,7 +110,7 @@ def get_or_create(
115
110
  Mutability.read_only,
116
111
  Mutability.immutable,
117
112
  ):
118
- raise SCIMException(Error.make_mutability_error())
113
+ raise MutabilityException()
119
114
  ret = getattr(model, attribute_name, None)
120
115
  if not ret:
121
116
  if model.get_field_multiplicity(attribute_name):
@@ -149,51 +144,56 @@ def handle_extension(resource: Resource, scim_name: str) -> tuple[BaseModel, str
149
144
  return resource, scim_name
150
145
 
151
146
 
152
- def model_validate_from_dict(field_root_type: type[BaseModel], value: dict) -> Any:
153
- """Workaround for some of the "special" requirements for MS Entra, mixing display and displayName in some cases."""
154
- if (
155
- "display" not in value
156
- and "display" in field_root_type.model_fields
157
- and "displayName" in value
158
- ):
159
- value["display"] = value["displayName"]
160
- del value["displayName"]
161
- return field_root_type.model_validate(value)
147
+ def parse_value(field_root_type: type, value: Any) -> Any:
148
+ """Parse a PATCH value according to the target field root type."""
149
+ if isinstance(value, dict):
150
+ if not hasattr(field_root_type, "model_fields"):
151
+ raise TypeError
152
+
153
+ # Work around mixed display/displayName payloads emitted by MS Entra.
154
+ if (
155
+ "display" not in value
156
+ and "display" in field_root_type.model_fields
157
+ and "displayName" in value
158
+ ):
159
+ value = value.copy()
160
+ value["display"] = value["displayName"]
161
+ del value["displayName"]
162
+ return field_root_type.model_validate(value)
163
+
164
+ if field_root_type is bool and isinstance(value, str):
165
+ return not value.lower() == "false"
166
+
167
+ if field_root_type is datetime.datetime and isinstance(value, str):
168
+ # ISO 8601 datetime format (notably with the Z suffix) are only supported from Python 3.11
169
+ if sys.version_info < (3, 11): # pragma: no cover
170
+ return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", value))
171
+ return datetime.datetime.fromisoformat(value)
172
+
173
+ if field_root_type is EmailStr and isinstance(value, str):
174
+ return value
175
+
176
+ if hasattr(field_root_type, "model_fields"):
177
+ primary_value = get_by_alias(field_root_type, "value", True)
178
+ if primary_value is not None:
179
+ return field_root_type(value=value)
180
+ raise TypeError
181
+
182
+ return field_root_type(value)
162
183
 
163
184
 
164
185
  def parse_new_value(model: BaseModel, attribute_name: str, value: Any) -> Any:
165
186
  """Given a model and attribute name, attempt to parse a new value so that the type matches the type expected by the model.
166
187
 
167
- :raises SCIMException: If attribute can not be mapped to the
168
- required type
188
+ :raises InvalidValueException: If attribute can not be mapped to
189
+ the required type
169
190
  """
170
191
  field_root_type = model.get_field_root_type(attribute_name)
171
192
  try:
172
- if isinstance(value, dict):
173
- new_value = model_validate_from_dict(field_root_type, value)
174
- elif isinstance(value, list):
175
- new_value = [model_validate_from_dict(field_root_type, v) for v in value]
193
+ if isinstance(value, list):
194
+ new_value = [parse_value(field_root_type, v) for v in value]
176
195
  else:
177
- if field_root_type is bool and isinstance(value, str):
178
- new_value = not value.lower() == "false"
179
- elif field_root_type is datetime.datetime and isinstance(value, str):
180
- # ISO 8601 datetime format (notably with the Z suffix) are only supported from Python 3.11
181
- if sys.version_info < (3, 11): # pragma: no cover
182
- new_value = datetime.datetime.fromisoformat(
183
- re.sub(r"Z$", "+00:00", value)
184
- )
185
- else:
186
- new_value = datetime.datetime.fromisoformat(value)
187
- elif field_root_type is EmailStr and isinstance(value, str):
188
- new_value = value
189
- elif hasattr(field_root_type, "model_fields"):
190
- primary_value = get_by_alias(field_root_type, "value", True)
191
- if primary_value is not None:
192
- new_value = field_root_type(value=value)
193
- else:
194
- raise TypeError
195
- else:
196
- new_value = field_root_type(value)
196
+ new_value = parse_value(field_root_type, value)
197
197
  except (AttributeError, TypeError, ValueError, ValidationError) as e:
198
- raise SCIMException(Error.make_invalid_value_error()) from e
198
+ raise InvalidValueException() from e
199
199
  return new_value
File without changes