scim2-models 0.5.2__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scim2_models/__init__.py CHANGED
@@ -7,6 +7,18 @@ from .attributes import ComplexAttribute
7
7
  from .attributes import MultiValuedComplexAttribute
8
8
  from .base import BaseModel
9
9
  from .context import Context
10
+ from .exceptions import InvalidFilterException
11
+ from .exceptions import InvalidPathException
12
+ from .exceptions import InvalidSyntaxException
13
+ from .exceptions import InvalidValueException
14
+ from .exceptions import InvalidVersionException
15
+ from .exceptions import MutabilityException
16
+ from .exceptions import NoTargetException
17
+ from .exceptions import PathNotFoundException
18
+ from .exceptions import SCIMException
19
+ from .exceptions import SensitiveException
20
+ from .exceptions import TooManyException
21
+ from .exceptions import UniquenessException
10
22
  from .messages.bulk import BulkOperation
11
23
  from .messages.bulk import BulkRequest
12
24
  from .messages.bulk import BulkResponse
@@ -16,6 +28,10 @@ from .messages.message import Message
16
28
  from .messages.patch_op import PatchOp
17
29
  from .messages.patch_op import PatchOperation
18
30
  from .messages.search_request import SearchRequest
31
+ from .path import URN
32
+ from .path import Path
33
+ from .reference import URI
34
+ from .reference import External
19
35
  from .reference import ExternalReference
20
36
  from .reference import Reference
21
37
  from .reference import URIReference
@@ -72,6 +88,7 @@ __all__ = [
72
88
  "EnterpriseUser",
73
89
  "Entitlement",
74
90
  "Error",
91
+ "External",
75
92
  "ExternalReference",
76
93
  "Extension",
77
94
  "Filter",
@@ -79,13 +96,22 @@ __all__ = [
79
96
  "GroupMember",
80
97
  "GroupMembership",
81
98
  "Im",
99
+ "InvalidFilterException",
100
+ "InvalidPathException",
101
+ "InvalidSyntaxException",
102
+ "InvalidValueException",
103
+ "InvalidVersionException",
82
104
  "ListResponse",
83
105
  "Manager",
84
106
  "Message",
85
107
  "Meta",
86
108
  "Mutability",
109
+ "MutabilityException",
87
110
  "MultiValuedComplexAttribute",
88
111
  "Name",
112
+ "NoTargetException",
113
+ "Path",
114
+ "PathNotFoundException",
89
115
  "Patch",
90
116
  "PatchOp",
91
117
  "PatchOperation",
@@ -97,13 +123,19 @@ __all__ = [
97
123
  "ResourceType",
98
124
  "Returned",
99
125
  "Role",
126
+ "SCIMException",
100
127
  "Schema",
101
128
  "SchemaExtension",
102
129
  "SearchRequest",
130
+ "SensitiveException",
103
131
  "ServiceProviderConfig",
104
132
  "Sort",
133
+ "TooManyException",
105
134
  "Uniqueness",
135
+ "UniquenessException",
136
+ "URI",
106
137
  "URIReference",
138
+ "URN",
107
139
  "User",
108
140
  "X509Certificate",
109
141
  ]
@@ -34,7 +34,11 @@ class MultiValuedComplexAttribute(ComplexAttribute):
34
34
 
35
35
  primary: bool | None = None
36
36
  """A Boolean value indicating the 'primary' or preferred attribute value
37
- for this attribute."""
37
+ for this attribute.
38
+
39
+ Per :rfc:`RFC 7643 §2.4 <7643#section-2.4>`, the primary attribute value
40
+ ``True`` MUST appear no more than once in a multi-valued attribute list.
41
+ """
38
42
 
39
43
  display: Annotated[str | None, Mutability.immutable] = None
40
44
  """A human-readable name, primarily used for display purposes."""
scim2_models/base.py CHANGED
@@ -364,6 +364,49 @@ class BaseModel(PydanticBaseModel):
364
364
  cls._check_mutability_issues(original, obj)
365
365
  return obj
366
366
 
367
+ @model_validator(mode="after")
368
+ def check_primary_attribute_uniqueness(self, info: ValidationInfo) -> Self:
369
+ """Validate that only one attribute can be marked as primary in multi-valued lists.
370
+
371
+ Per RFC 7643 Section 2.4: The primary attribute value 'true' MUST appear no more than once.
372
+ """
373
+ scim_context = info.context.get("scim") if info.context else None
374
+ if not scim_context or scim_context == Context.DEFAULT:
375
+ return self
376
+
377
+ for field_name in self.__class__.model_fields:
378
+ if not self.get_field_multiplicity(field_name):
379
+ continue
380
+
381
+ field_value = getattr(self, field_name)
382
+ if field_value is None:
383
+ continue
384
+
385
+ element_type = self.get_field_root_type(field_name)
386
+ if (
387
+ element_type is None
388
+ or not isclass(element_type)
389
+ or not issubclass(element_type, PydanticBaseModel)
390
+ or "primary" not in element_type.model_fields
391
+ ):
392
+ continue
393
+
394
+ primary_count = sum(
395
+ 1 for item in field_value if getattr(item, "primary", None) is True
396
+ )
397
+
398
+ if primary_count > 1:
399
+ raise PydanticCustomError(
400
+ "primary_uniqueness_error",
401
+ "Field '{field_name}' has {count} items marked as primary, but only one is allowed per RFC 7643",
402
+ {
403
+ "field_name": field_name,
404
+ "count": primary_count,
405
+ },
406
+ )
407
+
408
+ return self
409
+
367
410
  @classmethod
368
411
  def _check_mutability_issues(
369
412
  cls, original: "BaseModel", replacement: "BaseModel"
@@ -406,7 +449,9 @@ class BaseModel(PydanticBaseModel):
406
449
  main_schema = self._attribute_urn
407
450
  separator = "."
408
451
  else:
409
- main_schema = self.__class__.model_fields["schemas"].default[0]
452
+ main_schema = getattr(self.__class__, "__schema__", None)
453
+ if main_schema is None:
454
+ return
410
455
  separator = ":"
411
456
 
412
457
  for field_name in self.__class__.model_fields:
@@ -540,13 +585,12 @@ class BaseModel(PydanticBaseModel):
540
585
  """
541
586
  from scim2_models.resources.resource import Extension
542
587
 
543
- main_schema = self.__class__.model_fields["schemas"].default[0]
588
+ main_schema = getattr(self.__class__, "__schema__", None)
544
589
  field = self.__class__.model_fields[field_name]
545
590
  alias = field.serialization_alias or field_name
546
591
  field_type = self.get_field_root_type(field_name)
547
- full_urn = (
548
- alias
549
- if isclass(field_type) and issubclass(field_type, Extension)
550
- else f"{main_schema}:{alias}"
551
- )
552
- return full_urn
592
+ if isclass(field_type) and issubclass(field_type, Extension):
593
+ return alias
594
+ if main_schema is None:
595
+ return alias
596
+ return f"{main_schema}:{alias}"
@@ -0,0 +1,265 @@
1
+ """SCIM exceptions corresponding to RFC 7644 error types.
2
+
3
+ This module provides a hierarchy of exceptions that map to SCIM protocol errors.
4
+ Each exception can be converted to a :class:`~scim2_models.Error` response object
5
+ or to a :class:`~pydantic_core.PydanticCustomError` for use in Pydantic validators.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING
9
+ from typing import Any
10
+
11
+ from pydantic_core import PydanticCustomError
12
+
13
+ if TYPE_CHECKING:
14
+ from .messages.error import Error
15
+
16
+
17
+ class SCIMException(Exception):
18
+ """Base exception for SCIM protocol errors.
19
+
20
+ Each subclass corresponds to a scimType defined in :rfc:`RFC 7644 Table 9 <7644#section-3.12>`.
21
+ """
22
+
23
+ status: int = 400
24
+ scim_type: str = ""
25
+ _default_detail: str = "A SCIM error occurred"
26
+
27
+ def __init__(self, *, detail: str | None = None, **context: Any):
28
+ self.context = context
29
+ self._detail = detail
30
+ super().__init__(detail or self._default_detail)
31
+
32
+ @property
33
+ def detail(self) -> str:
34
+ """The error detail message."""
35
+ return self._detail or self._default_detail
36
+
37
+ def to_error(self) -> "Error":
38
+ """Convert this exception to a SCIM Error response object."""
39
+ from .messages.error import Error
40
+
41
+ return Error(
42
+ status=self.status,
43
+ scim_type=self.scim_type or None,
44
+ detail=str(self),
45
+ )
46
+
47
+ def as_pydantic_error(self) -> PydanticCustomError:
48
+ """Convert to PydanticCustomError for use in Pydantic validators."""
49
+ return PydanticCustomError(
50
+ f"scim_{self.scim_type}" if self.scim_type else "scim_error",
51
+ str(self),
52
+ {"scim_type": self.scim_type, "status": self.status, **self.context},
53
+ )
54
+
55
+
56
+ class InvalidFilterException(SCIMException):
57
+ """The specified filter syntax was invalid.
58
+
59
+ Corresponds to scimType ``invalidFilter`` with HTTP status 400.
60
+
61
+ :rfc:`RFC 7644 Section 3.4.2.2 <7644#section-3.4.2.2>`
62
+ """
63
+
64
+ status = 400
65
+ scim_type = "invalidFilter"
66
+ _default_detail = (
67
+ "The specified filter syntax was invalid, "
68
+ "or the specified attribute and filter comparison combination is not supported"
69
+ )
70
+
71
+ def __init__(self, *, filter: str | None = None, **kw: Any):
72
+ self.filter = filter
73
+ super().__init__(**kw)
74
+
75
+
76
+ class TooManyException(SCIMException):
77
+ """The specified filter yields too many results.
78
+
79
+ Corresponds to scimType ``tooMany`` with HTTP status 400.
80
+
81
+ :rfc:`RFC 7644 Section 3.4.2.2 <7644#section-3.4.2.2>`
82
+ """
83
+
84
+ status = 400
85
+ scim_type = "tooMany"
86
+ _default_detail = (
87
+ "The specified filter yields many more results "
88
+ "than the server is willing to calculate or process"
89
+ )
90
+
91
+
92
+ class UniquenessException(SCIMException):
93
+ """One or more attribute values are already in use or reserved.
94
+
95
+ Corresponds to scimType ``uniqueness`` with HTTP status 409.
96
+
97
+ :rfc:`RFC 7644 Section 3.3.1 <7644#section-3.3.1>`
98
+ """
99
+
100
+ status = 409
101
+ scim_type = "uniqueness"
102
+ _default_detail = (
103
+ "One or more of the attribute values are already in use or are reserved"
104
+ )
105
+
106
+ def __init__(
107
+ self, *, attribute: str | None = None, value: Any | None = None, **kw: Any
108
+ ):
109
+ self.attribute = attribute
110
+ self.value = value
111
+ super().__init__(**kw)
112
+
113
+
114
+ class MutabilityException(SCIMException):
115
+ """The attempted modification is not compatible with the attribute's mutability.
116
+
117
+ Corresponds to scimType ``mutability`` with HTTP status 400.
118
+
119
+ :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>`
120
+ """
121
+
122
+ status = 400
123
+ scim_type = "mutability"
124
+ _default_detail = (
125
+ "The attempted modification is not compatible with the target attribute's "
126
+ "mutability or current state"
127
+ )
128
+
129
+ def __init__(
130
+ self,
131
+ *,
132
+ attribute: str | None = None,
133
+ mutability: str | None = None,
134
+ operation: str | None = None,
135
+ **kw: Any,
136
+ ):
137
+ self.attribute = attribute
138
+ self.mutability = mutability
139
+ self.operation = operation
140
+ super().__init__(**kw)
141
+
142
+
143
+ class InvalidSyntaxException(SCIMException):
144
+ """The request body message structure was invalid.
145
+
146
+ Corresponds to scimType ``invalidSyntax`` with HTTP status 400.
147
+
148
+ :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>`
149
+ """
150
+
151
+ status = 400
152
+ scim_type = "invalidSyntax"
153
+ _default_detail = (
154
+ "The request body message structure was invalid "
155
+ "or did not conform to the request schema"
156
+ )
157
+
158
+
159
+ class InvalidPathException(SCIMException):
160
+ """The path attribute was invalid or malformed.
161
+
162
+ Corresponds to scimType ``invalidPath`` with HTTP status 400.
163
+
164
+ :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>`
165
+ """
166
+
167
+ status = 400
168
+ scim_type = "invalidPath"
169
+ _default_detail = "The path attribute was invalid or malformed"
170
+
171
+ def __init__(self, *, path: str | None = None, **kw: Any):
172
+ self.path = path
173
+ super().__init__(**kw)
174
+
175
+
176
+ class PathNotFoundException(InvalidPathException):
177
+ """The path references a non-existent field.
178
+
179
+ This is a specialized form of :class:`InvalidPathException`.
180
+ """
181
+
182
+ _default_detail = "The specified path references a non-existent field"
183
+
184
+ def __init__(self, *, path: str | None = None, field: str | None = None, **kw: Any):
185
+ self.field = field
186
+ super().__init__(path=path, **kw)
187
+
188
+ def __str__(self) -> str:
189
+ if self._detail:
190
+ return self._detail
191
+ if self.field:
192
+ return f"Field not found: {self.field}"
193
+ return self._default_detail
194
+
195
+
196
+ class NoTargetException(SCIMException):
197
+ """The specified path did not yield a target that could be operated on.
198
+
199
+ Corresponds to scimType ``noTarget`` with HTTP status 400.
200
+
201
+ :rfc:`RFC 7644 Section 3.5.2 <7644#section-3.5.2>`
202
+ """
203
+
204
+ status = 400
205
+ scim_type = "noTarget"
206
+ _default_detail = (
207
+ "The specified path did not yield an attribute or attribute value "
208
+ "that could be operated on"
209
+ )
210
+
211
+ def __init__(self, *, path: str | None = None, **kw: Any):
212
+ self.path = path
213
+ super().__init__(**kw)
214
+
215
+
216
+ class InvalidValueException(SCIMException):
217
+ """A required value was missing or the value was not compatible.
218
+
219
+ Corresponds to scimType ``invalidValue`` with HTTP status 400.
220
+
221
+ :rfc:`RFC 7644 Section 3.12 <7644#section-3.12>`
222
+ """
223
+
224
+ status = 400
225
+ scim_type = "invalidValue"
226
+ _default_detail = (
227
+ "A required value was missing, or the value specified was not compatible "
228
+ "with the operation or attribute type, or resource schema"
229
+ )
230
+
231
+ def __init__(
232
+ self, *, attribute: str | None = None, reason: str | None = None, **kw: Any
233
+ ):
234
+ self.attribute = attribute
235
+ self.reason = reason
236
+ super().__init__(**kw)
237
+
238
+
239
+ class InvalidVersionException(SCIMException):
240
+ """The specified SCIM protocol version is not supported.
241
+
242
+ Corresponds to scimType ``invalidVers`` with HTTP status 400.
243
+
244
+ :rfc:`RFC 7644 Section 3.13 <7644#section-3.13>`
245
+ """
246
+
247
+ status = 400
248
+ scim_type = "invalidVers"
249
+ _default_detail = "The specified SCIM protocol version is not supported"
250
+
251
+
252
+ class SensitiveException(SCIMException):
253
+ """The request cannot be completed due to sensitive information in the URI.
254
+
255
+ Corresponds to scimType ``sensitive`` with HTTP status 400.
256
+
257
+ :rfc:`RFC 7644 Section 7.5.2 <7644#section-7.5.2>`
258
+ """
259
+
260
+ status = 400
261
+ scim_type = "sensitive"
262
+ _default_detail = (
263
+ "The specified request cannot be completed, due to the passing of sensitive "
264
+ "information in a request URI"
265
+ )
@@ -5,8 +5,8 @@ from typing import Any
5
5
  from pydantic import Field
6
6
  from pydantic import PlainSerializer
7
7
 
8
- from ..annotations import Required
9
8
  from ..attributes import ComplexAttribute
9
+ from ..path import URN
10
10
  from ..utils import _int_to_str
11
11
  from .message import Message
12
12
 
@@ -53,9 +53,7 @@ class BulkRequest(Message):
53
53
  The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
54
54
  """
55
55
 
56
- schemas: Annotated[list[str], Required.true] = [
57
- "urn:ietf:params:scim:api:messages:2.0:BulkRequest"
58
- ]
56
+ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkRequest")
59
57
 
60
58
  fail_on_errors: int | None = None
61
59
  """An integer specifying the number of errors that the service provider
@@ -76,9 +74,7 @@ class BulkResponse(Message):
76
74
  The models for Bulk operations are defined, but their behavior is not implemented nor tested yet.
77
75
  """
78
76
 
79
- schemas: Annotated[list[str], Required.true] = [
80
- "urn:ietf:params:scim:api:messages:2.0:BulkResponse"
81
- ]
77
+ __schema__ = URN("urn:ietf:params:scim:api:messages:2.0:BulkResponse")
82
78
 
83
79
  operations: list[BulkOperation] | None = Field(
84
80
  None, serialization_alias="Operations"