scim2-models 0.5.1__tar.gz → 0.6.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.
Files changed (37) hide show
  1. {scim2_models-0.5.1 → scim2_models-0.6.0}/PKG-INFO +2 -2
  2. {scim2_models-0.5.1 → scim2_models-0.6.0}/pyproject.toml +2 -2
  3. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/__init__.py +32 -0
  4. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/attributes.py +5 -1
  5. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/base.py +68 -17
  6. scim2_models-0.6.0/scim2_models/exceptions.py +265 -0
  7. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/messages/bulk.py +3 -7
  8. scim2_models-0.6.0/scim2_models/messages/error.py +284 -0
  9. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/messages/list_response.py +2 -5
  10. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/messages/message.py +4 -4
  11. scim2_models-0.6.0/scim2_models/messages/patch_op.py +402 -0
  12. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/messages/search_request.py +9 -57
  13. scim2_models-0.6.0/scim2_models/path.py +731 -0
  14. scim2_models-0.6.0/scim2_models/reference.py +171 -0
  15. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/enterprise_user.py +10 -8
  16. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/group.py +9 -7
  17. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/resource.py +110 -23
  18. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/resource_type.py +15 -14
  19. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/schema.py +15 -20
  20. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/service_provider_config.py +10 -13
  21. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/user.py +13 -10
  22. scim2_models-0.6.0/scim2_models/scim_object.py +148 -0
  23. scim2_models-0.6.0/scim2_models/utils.py +64 -0
  24. scim2_models-0.5.1/scim2_models/messages/error.py +0 -115
  25. scim2_models-0.5.1/scim2_models/messages/patch_op.py +0 -536
  26. scim2_models-0.5.1/scim2_models/reference.py +0 -80
  27. scim2_models-0.5.1/scim2_models/scim_object.py +0 -65
  28. scim2_models-0.5.1/scim2_models/urn.py +0 -126
  29. scim2_models-0.5.1/scim2_models/utils.py +0 -204
  30. {scim2_models-0.5.1 → scim2_models-0.6.0}/LICENSE +0 -0
  31. {scim2_models-0.5.1 → scim2_models-0.6.0}/README.md +0 -0
  32. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/annotations.py +0 -0
  33. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/constants.py +0 -0
  34. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/context.py +0 -0
  35. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/messages/__init__.py +0 -0
  36. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/py.typed +0 -0
  37. {scim2_models-0.5.1 → scim2_models-0.6.0}/scim2_models/resources/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: scim2-models
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: SCIM2 models serialization and validation with pydantic
5
5
  Keywords: scim,scim2,provisioning,pydantic,rfc7643,rfc7644
6
6
  Author: Yaal Coop
@@ -218,7 +218,7 @@ Classifier: License :: OSI Approved :: Apache Software License
218
218
  Classifier: Environment :: Web Environment
219
219
  Classifier: Programming Language :: Python
220
220
  Classifier: Operating System :: OS Independent
221
- Requires-Dist: pydantic[email]>=2.7.0
221
+ Requires-Dist: pydantic[email]>=2.12.0
222
222
  Requires-Python: >=3.10
223
223
  Project-URL: changelog, https://scim2-models.readthedocs.io/en/latest/changelog.html
224
224
  Project-URL: documentation, https://scim2-models.readthedocs.io
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-models"
7
- version = "0.5.1"
7
+ version = "0.6.0"
8
8
  description = "SCIM2 models serialization and validation with pydantic"
9
9
  authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
10
10
  license = {file = "LICENSE"}
@@ -27,7 +27,7 @@ classifiers = [
27
27
 
28
28
  requires-python = ">= 3.10"
29
29
  dependencies = [
30
- "pydantic[email]>=2.7.0"
30
+ "pydantic[email]>=2.12.0"
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -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."""
@@ -29,12 +29,21 @@ from scim2_models.utils import _normalize_attribute_name
29
29
  from scim2_models.utils import _to_camel
30
30
 
31
31
 
32
- def _contains_attribute_or_subattributes(
33
- attribute_urns: list[str], attribute_urn: str
34
- ) -> bool:
35
- return attribute_urn in attribute_urns or any(
36
- item.startswith(f"{attribute_urn}.") or item.startswith(f"{attribute_urn}:")
37
- for item in attribute_urns
32
+ def _is_attribute_requested(requested_urns: list[str], current_urn: str) -> bool:
33
+ """Check if an attribute should be included based on the requested URNs.
34
+
35
+ Returns True if:
36
+ - The current attribute is explicitly requested
37
+ - A sub-attribute of the current attribute is requested
38
+ - The current attribute is a sub-attribute of a requested attribute
39
+ """
40
+ return (
41
+ current_urn in requested_urns
42
+ or any(
43
+ item.startswith(f"{current_urn}.") or item.startswith(f"{current_urn}:")
44
+ for item in requested_urns
45
+ )
46
+ or any(current_urn.startswith(f"{item}.") for item in requested_urns)
38
47
  )
39
48
 
40
49
 
@@ -355,6 +364,49 @@ class BaseModel(PydanticBaseModel):
355
364
  cls._check_mutability_issues(original, obj)
356
365
  return obj
357
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
+
358
410
  @classmethod
359
411
  def _check_mutability_issues(
360
412
  cls, original: "BaseModel", replacement: "BaseModel"
@@ -397,7 +449,9 @@ class BaseModel(PydanticBaseModel):
397
449
  main_schema = self._attribute_urn
398
450
  separator = "."
399
451
  else:
400
- 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
401
455
  separator = ":"
402
456
 
403
457
  for field_name in self.__class__.model_fields:
@@ -478,9 +532,7 @@ class BaseModel(PydanticBaseModel):
478
532
  if returnability == Returned.default and (
479
533
  (
480
534
  included_urns
481
- and not _contains_attribute_or_subattributes(
482
- included_urns, attribute_urn
483
- )
535
+ and not _is_attribute_requested(included_urns, attribute_urn)
484
536
  )
485
537
  or attribute_urn in excluded_urns
486
538
  ):
@@ -533,13 +585,12 @@ class BaseModel(PydanticBaseModel):
533
585
  """
534
586
  from scim2_models.resources.resource import Extension
535
587
 
536
- main_schema = self.__class__.model_fields["schemas"].default[0]
588
+ main_schema = getattr(self.__class__, "__schema__", None)
537
589
  field = self.__class__.model_fields[field_name]
538
590
  alias = field.serialization_alias or field_name
539
591
  field_type = self.get_field_root_type(field_name)
540
- full_urn = (
541
- alias
542
- if isclass(field_type) and issubclass(field_type, Extension)
543
- else f"{main_schema}:{alias}"
544
- )
545
- 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"