scim2-models 0.3.6__py3-none-any.whl → 0.3.7__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 +11 -11
- scim2_models/annotations.py +104 -0
- scim2_models/attributes.py +57 -0
- scim2_models/base.py +68 -525
- scim2_models/constants.py +3 -3
- scim2_models/context.py +168 -0
- scim2_models/reference.py +82 -0
- scim2_models/rfc7643/enterprise_user.py +4 -4
- scim2_models/rfc7643/group.py +5 -5
- scim2_models/rfc7643/resource.py +202 -95
- scim2_models/rfc7643/resource_type.py +13 -22
- scim2_models/rfc7643/schema.py +44 -38
- scim2_models/rfc7643/service_provider_config.py +7 -7
- scim2_models/rfc7643/user.py +9 -9
- scim2_models/rfc7644/bulk.py +2 -2
- scim2_models/rfc7644/error.py +11 -11
- scim2_models/rfc7644/list_response.py +5 -66
- scim2_models/rfc7644/message.py +103 -3
- scim2_models/rfc7644/patch_op.py +20 -6
- scim2_models/rfc7644/search_request.py +4 -4
- scim2_models/scim_object.py +143 -0
- scim2_models/utils.py +1 -1
- {scim2_models-0.3.6.dist-info → scim2_models-0.3.7.dist-info}/METADATA +1 -1
- scim2_models-0.3.7.dist-info/RECORD +29 -0
- scim2_models-0.3.6.dist-info/RECORD +0 -24
- {scim2_models-0.3.6.dist-info → scim2_models-0.3.7.dist-info}/WHEEL +0 -0
- {scim2_models-0.3.6.dist-info → scim2_models-0.3.7.dist-info}/licenses/LICENSE +0 -0
scim2_models/rfc7643/schema.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
|
6
6
|
from typing import List # noqa : UP005,UP035
|
|
7
7
|
from typing import Literal
|
|
8
8
|
from typing import Optional
|
|
9
|
+
from typing import TypeVar
|
|
9
10
|
from typing import Union
|
|
10
11
|
from typing import get_origin
|
|
11
12
|
|
|
@@ -16,24 +17,25 @@ from pydantic.alias_generators import to_pascal
|
|
|
16
17
|
from pydantic.alias_generators import to_snake
|
|
17
18
|
from pydantic_core import Url
|
|
18
19
|
|
|
20
|
+
from ..annotations import CaseExact
|
|
21
|
+
from ..annotations import Mutability
|
|
22
|
+
from ..annotations import Required
|
|
23
|
+
from ..annotations import Returned
|
|
24
|
+
from ..annotations import Uniqueness
|
|
25
|
+
from ..attributes import ComplexAttribute
|
|
26
|
+
from ..attributes import MultiValuedComplexAttribute
|
|
27
|
+
from ..attributes import is_complex_attribute
|
|
19
28
|
from ..base import BaseModel
|
|
20
|
-
from ..base import CaseExact
|
|
21
|
-
from ..base import ComplexAttribute
|
|
22
|
-
from ..base import ExternalReference
|
|
23
|
-
from ..base import MultiValuedComplexAttribute
|
|
24
|
-
from ..base import Mutability
|
|
25
|
-
from ..base import Reference
|
|
26
|
-
from ..base import Required
|
|
27
|
-
from ..base import Returned
|
|
28
|
-
from ..base import Uniqueness
|
|
29
|
-
from ..base import URIReference
|
|
30
|
-
from ..base import is_complex_attribute
|
|
31
29
|
from ..constants import RESERVED_WORDS
|
|
30
|
+
from ..reference import ExternalReference
|
|
31
|
+
from ..reference import Reference
|
|
32
|
+
from ..reference import URIReference
|
|
32
33
|
from ..utils import Base64Bytes
|
|
33
34
|
from ..utils import normalize_attribute_name
|
|
34
|
-
from .resource import Extension
|
|
35
35
|
from .resource import Resource
|
|
36
36
|
|
|
37
|
+
T = TypeVar("T", bound=BaseModel)
|
|
38
|
+
|
|
37
39
|
|
|
38
40
|
def make_python_identifier(identifier: str) -> str:
|
|
39
41
|
"""Sanitize string to be a suitable Python/Pydantic class attribute name."""
|
|
@@ -46,9 +48,9 @@ def make_python_identifier(identifier: str) -> str:
|
|
|
46
48
|
|
|
47
49
|
def make_python_model(
|
|
48
50
|
obj: Union["Schema", "Attribute"],
|
|
49
|
-
base:
|
|
50
|
-
multiple=False,
|
|
51
|
-
) ->
|
|
51
|
+
base: type[T],
|
|
52
|
+
multiple: bool = False,
|
|
53
|
+
) -> type[T]:
|
|
52
54
|
"""Build a Python model from a Schema or an Attribute object."""
|
|
53
55
|
if isinstance(obj, Attribute):
|
|
54
56
|
pydantic_attributes = {
|
|
@@ -56,7 +58,6 @@ def make_python_model(
|
|
|
56
58
|
for attr in (obj.sub_attributes or [])
|
|
57
59
|
if attr.name
|
|
58
60
|
}
|
|
59
|
-
base = MultiValuedComplexAttribute if multiple else ComplexAttribute
|
|
60
61
|
|
|
61
62
|
else:
|
|
62
63
|
pydantic_attributes = {
|
|
@@ -69,14 +70,17 @@ def make_python_model(
|
|
|
69
70
|
Field(default=[obj.id]),
|
|
70
71
|
)
|
|
71
72
|
|
|
73
|
+
if not obj.name:
|
|
74
|
+
raise ValueError("Schema or Attribute 'name' must be defined")
|
|
75
|
+
|
|
72
76
|
model_name = to_pascal(to_snake(obj.name))
|
|
73
|
-
model = create_model(model_name, __base__=base, **pydantic_attributes)
|
|
77
|
+
model: type[T] = create_model(model_name, __base__=base, **pydantic_attributes) # type: ignore[call-overload]
|
|
74
78
|
|
|
75
79
|
# Set the ComplexType class as a member of the model
|
|
76
80
|
# e.g. make Member an attribute of Group
|
|
77
81
|
for attr_name in model.model_fields:
|
|
78
82
|
attr_type = model.get_field_root_type(attr_name)
|
|
79
|
-
if is_complex_attribute(attr_type):
|
|
83
|
+
if attr_type and is_complex_attribute(attr_type):
|
|
80
84
|
setattr(model, attr_type.__name__, attr_type)
|
|
81
85
|
|
|
82
86
|
return model
|
|
@@ -95,7 +99,7 @@ class Attribute(ComplexAttribute):
|
|
|
95
99
|
|
|
96
100
|
def to_python(
|
|
97
101
|
self,
|
|
98
|
-
multiple=False,
|
|
102
|
+
multiple: bool = False,
|
|
99
103
|
reference_types: Optional[list[str]] = None,
|
|
100
104
|
) -> type:
|
|
101
105
|
if self.value == self.reference and reference_types is not None:
|
|
@@ -122,32 +126,32 @@ class Attribute(ComplexAttribute):
|
|
|
122
126
|
return attr_types[self.value]
|
|
123
127
|
|
|
124
128
|
@classmethod
|
|
125
|
-
def from_python(cls, pytype) ->
|
|
129
|
+
def from_python(cls, pytype: type) -> "Attribute.Type":
|
|
126
130
|
if get_origin(pytype) == Reference:
|
|
127
|
-
return cls.reference
|
|
131
|
+
return cls.reference
|
|
128
132
|
|
|
129
|
-
if is_complex_attribute(pytype):
|
|
130
|
-
return cls.complex
|
|
133
|
+
if pytype and is_complex_attribute(pytype):
|
|
134
|
+
return cls.complex
|
|
131
135
|
|
|
132
136
|
if pytype in (Required, CaseExact):
|
|
133
|
-
return cls.boolean
|
|
137
|
+
return cls.boolean
|
|
134
138
|
|
|
135
139
|
attr_types = {
|
|
136
|
-
str: cls.string
|
|
137
|
-
bool: cls.boolean
|
|
138
|
-
float: cls.decimal
|
|
139
|
-
int: cls.integer
|
|
140
|
-
datetime: cls.date_time
|
|
141
|
-
Base64Bytes: cls.binary
|
|
140
|
+
str: cls.string,
|
|
141
|
+
bool: cls.boolean,
|
|
142
|
+
float: cls.decimal,
|
|
143
|
+
int: cls.integer,
|
|
144
|
+
datetime: cls.date_time,
|
|
145
|
+
Base64Bytes: cls.binary,
|
|
142
146
|
}
|
|
143
|
-
return attr_types.get(pytype, cls.string
|
|
147
|
+
return attr_types.get(pytype, cls.string)
|
|
144
148
|
|
|
145
149
|
name: Annotated[
|
|
146
150
|
Optional[str], Mutability.read_only, Required.true, CaseExact.true
|
|
147
151
|
] = None
|
|
148
152
|
"""The attribute's name."""
|
|
149
153
|
|
|
150
|
-
type: Annotated[Type, Mutability.read_only, Required.true] = Field(
|
|
154
|
+
type: Annotated[Optional[Type], Mutability.read_only, Required.true] = Field(
|
|
151
155
|
None, examples=[item.value for item in Type]
|
|
152
156
|
)
|
|
153
157
|
"""The attribute's data type."""
|
|
@@ -206,15 +210,17 @@ class Attribute(ComplexAttribute):
|
|
|
206
210
|
"""When an attribute is of type "complex", "subAttributes" defines a set of
|
|
207
211
|
sub-attributes."""
|
|
208
212
|
|
|
209
|
-
def to_python(self) -> Optional[tuple[Any,
|
|
213
|
+
def to_python(self) -> Optional[tuple[Any, Any]]:
|
|
210
214
|
"""Build tuple suited to be passed to pydantic 'create_model'."""
|
|
211
|
-
if not self.name:
|
|
215
|
+
if not self.name or not self.type:
|
|
212
216
|
return None
|
|
213
217
|
|
|
214
|
-
attr_type = self.type.to_python(self.multi_valued, self.reference_types)
|
|
218
|
+
attr_type = self.type.to_python(bool(self.multi_valued), self.reference_types)
|
|
215
219
|
|
|
216
220
|
if attr_type in (ComplexAttribute, MultiValuedComplexAttribute):
|
|
217
|
-
attr_type = make_python_model(
|
|
221
|
+
attr_type = make_python_model(
|
|
222
|
+
obj=self, base=attr_type, multiple=bool(self.multi_valued)
|
|
223
|
+
)
|
|
218
224
|
|
|
219
225
|
if self.multi_valued:
|
|
220
226
|
attr_type = list[attr_type] # type: ignore
|
|
@@ -245,7 +251,7 @@ class Attribute(ComplexAttribute):
|
|
|
245
251
|
return sub_attribute
|
|
246
252
|
return None
|
|
247
253
|
|
|
248
|
-
def __getitem__(self, name):
|
|
254
|
+
def __getitem__(self, name: str) -> "Attribute":
|
|
249
255
|
"""Find an attribute by its name."""
|
|
250
256
|
if attribute := self.get_attribute(name):
|
|
251
257
|
return attribute
|
|
@@ -287,7 +293,7 @@ class Schema(Resource):
|
|
|
287
293
|
return attribute
|
|
288
294
|
return None
|
|
289
295
|
|
|
290
|
-
def __getitem__(self, name):
|
|
296
|
+
def __getitem__(self, name: str) -> "Attribute": # type: ignore[override]
|
|
291
297
|
"""Find an attribute by its name."""
|
|
292
298
|
if attribute := self.get_attribute(name):
|
|
293
299
|
return attribute
|
|
@@ -4,13 +4,13 @@ from typing import Optional
|
|
|
4
4
|
|
|
5
5
|
from pydantic import Field
|
|
6
6
|
|
|
7
|
-
from ..
|
|
8
|
-
from ..
|
|
9
|
-
from ..
|
|
10
|
-
from ..
|
|
11
|
-
from ..
|
|
12
|
-
from ..
|
|
13
|
-
from ..
|
|
7
|
+
from ..annotations import Mutability
|
|
8
|
+
from ..annotations import Required
|
|
9
|
+
from ..annotations import Returned
|
|
10
|
+
from ..annotations import Uniqueness
|
|
11
|
+
from ..attributes import ComplexAttribute
|
|
12
|
+
from ..reference import ExternalReference
|
|
13
|
+
from ..reference import Reference
|
|
14
14
|
from .resource import Resource
|
|
15
15
|
|
|
16
16
|
|
scim2_models/rfc7643/user.py
CHANGED
|
@@ -8,15 +8,15 @@ from typing import Union
|
|
|
8
8
|
from pydantic import EmailStr
|
|
9
9
|
from pydantic import Field
|
|
10
10
|
|
|
11
|
-
from ..
|
|
12
|
-
from ..
|
|
13
|
-
from ..
|
|
14
|
-
from ..
|
|
15
|
-
from ..
|
|
16
|
-
from ..
|
|
17
|
-
from ..
|
|
18
|
-
from ..
|
|
19
|
-
from ..
|
|
11
|
+
from ..annotations import CaseExact
|
|
12
|
+
from ..annotations import Mutability
|
|
13
|
+
from ..annotations import Required
|
|
14
|
+
from ..annotations import Returned
|
|
15
|
+
from ..annotations import Uniqueness
|
|
16
|
+
from ..attributes import ComplexAttribute
|
|
17
|
+
from ..attributes import MultiValuedComplexAttribute
|
|
18
|
+
from ..reference import ExternalReference
|
|
19
|
+
from ..reference import Reference
|
|
20
20
|
from ..utils import Base64Bytes
|
|
21
21
|
from .resource import AnyExtension
|
|
22
22
|
from .resource import Resource
|
scim2_models/rfc7644/bulk.py
CHANGED
|
@@ -6,8 +6,8 @@ from typing import Optional
|
|
|
6
6
|
from pydantic import Field
|
|
7
7
|
from pydantic import PlainSerializer
|
|
8
8
|
|
|
9
|
-
from ..
|
|
10
|
-
from ..
|
|
9
|
+
from ..annotations import Required
|
|
10
|
+
from ..attributes import ComplexAttribute
|
|
11
11
|
from ..utils import int_to_str
|
|
12
12
|
from .message import Message
|
|
13
13
|
|
scim2_models/rfc7644/error.py
CHANGED
|
@@ -3,7 +3,7 @@ from typing import Optional
|
|
|
3
3
|
|
|
4
4
|
from pydantic import PlainSerializer
|
|
5
5
|
|
|
6
|
-
from ..
|
|
6
|
+
from ..annotations import Required
|
|
7
7
|
from ..utils import int_to_str
|
|
8
8
|
from .message import Message
|
|
9
9
|
|
|
@@ -26,7 +26,7 @@ class Error(Message):
|
|
|
26
26
|
"""A detailed human-readable message."""
|
|
27
27
|
|
|
28
28
|
@classmethod
|
|
29
|
-
def make_invalid_filter_error(cls):
|
|
29
|
+
def make_invalid_filter_error(cls) -> "Error":
|
|
30
30
|
"""Pre-defined error intended to be raised when the specified filter syntax was invalid (does not comply with :rfc:`Figure 1 of RFC7644 <7644#section-3.4.2.2>`), or the specified attribute and filter comparison combination is not supported."""
|
|
31
31
|
return Error(
|
|
32
32
|
status=400,
|
|
@@ -35,7 +35,7 @@ class Error(Message):
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
@classmethod
|
|
38
|
-
def make_too_many_error(cls):
|
|
38
|
+
def make_too_many_error(cls) -> "Error":
|
|
39
39
|
"""Pre-defined error intended to be raised when the specified filter yields many more results than the server is willing to calculate or process. For example, a filter such as ``(userName pr)`` by itself would return all entries with a ``userName`` and MAY not be acceptable to the service provider."""
|
|
40
40
|
return Error(
|
|
41
41
|
status=400,
|
|
@@ -44,7 +44,7 @@ class Error(Message):
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
@classmethod
|
|
47
|
-
def make_uniqueness_error(cls):
|
|
47
|
+
def make_uniqueness_error(cls) -> "Error":
|
|
48
48
|
"""Pre-defined error intended to be raised when One or more of the attribute values are already in use or are reserved."""
|
|
49
49
|
return Error(
|
|
50
50
|
status=409,
|
|
@@ -53,7 +53,7 @@ class Error(Message):
|
|
|
53
53
|
)
|
|
54
54
|
|
|
55
55
|
@classmethod
|
|
56
|
-
def make_mutability_error(cls):
|
|
56
|
+
def make_mutability_error(cls) -> "Error":
|
|
57
57
|
"""Pre-defined error intended to be raised when the attempted modification is not compatible with the target attribute's mutability or current state (e.g., modification of an "immutable" attribute with an existing value)."""
|
|
58
58
|
return Error(
|
|
59
59
|
status=400,
|
|
@@ -62,7 +62,7 @@ class Error(Message):
|
|
|
62
62
|
)
|
|
63
63
|
|
|
64
64
|
@classmethod
|
|
65
|
-
def make_invalid_syntax_error(cls):
|
|
65
|
+
def make_invalid_syntax_error(cls) -> "Error":
|
|
66
66
|
"""Pre-defined error intended to be raised when the request body message structure was invalid or did not conform to the request schema."""
|
|
67
67
|
return Error(
|
|
68
68
|
status=400,
|
|
@@ -71,7 +71,7 @@ class Error(Message):
|
|
|
71
71
|
)
|
|
72
72
|
|
|
73
73
|
@classmethod
|
|
74
|
-
def make_invalid_path_error(cls):
|
|
74
|
+
def make_invalid_path_error(cls) -> "Error":
|
|
75
75
|
"""Pre-defined error intended to be raised when the "path" attribute was invalid or malformed (see :rfc:`Figure 7 of RFC7644 <7644#section-3.5.2>`)."""
|
|
76
76
|
return Error(
|
|
77
77
|
status=400,
|
|
@@ -80,7 +80,7 @@ class Error(Message):
|
|
|
80
80
|
)
|
|
81
81
|
|
|
82
82
|
@classmethod
|
|
83
|
-
def make_no_target_error(cls):
|
|
83
|
+
def make_no_target_error(cls) -> "Error":
|
|
84
84
|
"""Pre-defined error intended to be raised when the specified "path" did not yield an attribute or attribute value that could be operated on. This occurs when the specified "path" value contains a filter that yields no match."""
|
|
85
85
|
return Error(
|
|
86
86
|
status=400,
|
|
@@ -89,7 +89,7 @@ class Error(Message):
|
|
|
89
89
|
)
|
|
90
90
|
|
|
91
91
|
@classmethod
|
|
92
|
-
def make_invalid_value_error(cls):
|
|
92
|
+
def make_invalid_value_error(cls) -> "Error":
|
|
93
93
|
"""Pre-defined error intended to be raised when a required value was missing, or the value specified was not compatible with the operation or attribute type (see :rfc:`Section 2.2 of RFC7643 <7643#section-2.2>`), or resource schema (see :rfc:`Section 4 of RFC7643 <7643#section-4>`)."""
|
|
94
94
|
return Error(
|
|
95
95
|
status=400,
|
|
@@ -98,7 +98,7 @@ class Error(Message):
|
|
|
98
98
|
)
|
|
99
99
|
|
|
100
100
|
@classmethod
|
|
101
|
-
def make_invalid_version_error(cls):
|
|
101
|
+
def make_invalid_version_error(cls) -> "Error":
|
|
102
102
|
"""Pre-defined error intended to be raised when the specified SCIM protocol version is not supported (see :rfc:`Section 3.13 of RFC7644 <7644#section-3.13>`)."""
|
|
103
103
|
return Error(
|
|
104
104
|
status=400,
|
|
@@ -107,7 +107,7 @@ class Error(Message):
|
|
|
107
107
|
)
|
|
108
108
|
|
|
109
109
|
@classmethod
|
|
110
|
-
def make_sensitive_error(cls):
|
|
110
|
+
def make_sensitive_error(cls) -> "Error":
|
|
111
111
|
"""Pre-defined error intended to be raised when the specified request cannot be completed, due to the passing of sensitive (e.g., personal) information in a request URI. For example, personal information SHALL NOT be transmitted over request URIs. See :rfc:`Section 7.5.2 of RFC7644 <7644#section-7.5.2>`."""
|
|
112
112
|
return Error(
|
|
113
113
|
status=400,
|
|
@@ -2,84 +2,22 @@ from typing import Annotated
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
from typing import Generic
|
|
4
4
|
from typing import Optional
|
|
5
|
-
from typing import Union
|
|
6
|
-
from typing import get_args
|
|
7
|
-
from typing import get_origin
|
|
8
5
|
|
|
9
|
-
from pydantic import Discriminator
|
|
10
6
|
from pydantic import Field
|
|
11
|
-
from pydantic import Tag
|
|
12
7
|
from pydantic import ValidationInfo
|
|
13
8
|
from pydantic import ValidatorFunctionWrapHandler
|
|
14
9
|
from pydantic import model_validator
|
|
15
10
|
from pydantic_core import PydanticCustomError
|
|
16
11
|
from typing_extensions import Self
|
|
17
12
|
|
|
18
|
-
from ..
|
|
19
|
-
from ..
|
|
20
|
-
from ..base import Context
|
|
21
|
-
from ..base import Required
|
|
13
|
+
from ..annotations import Required
|
|
14
|
+
from ..context import Context
|
|
22
15
|
from ..rfc7643.resource import AnyResource
|
|
23
|
-
from
|
|
16
|
+
from .message import GenericMessageMetaclass
|
|
24
17
|
from .message import Message
|
|
25
18
|
|
|
26
19
|
|
|
27
|
-
class
|
|
28
|
-
def tagged_resource_union(resource_union):
|
|
29
|
-
"""Build Discriminated Unions, so pydantic can guess which class are needed to instantiate by inspecting a payload.
|
|
30
|
-
|
|
31
|
-
https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions
|
|
32
|
-
"""
|
|
33
|
-
if get_origin(resource_union) not in UNION_TYPES:
|
|
34
|
-
return resource_union
|
|
35
|
-
|
|
36
|
-
resource_types = get_args(resource_union)
|
|
37
|
-
|
|
38
|
-
def get_schema_from_payload(payload: Any) -> Optional[str]:
|
|
39
|
-
if not payload:
|
|
40
|
-
return None
|
|
41
|
-
|
|
42
|
-
payload_schemas = (
|
|
43
|
-
payload.get("schemas", [])
|
|
44
|
-
if isinstance(payload, dict)
|
|
45
|
-
else payload.schemas
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
resource_types_schemas = [
|
|
49
|
-
resource_type.model_fields["schemas"].default[0]
|
|
50
|
-
for resource_type in resource_types
|
|
51
|
-
]
|
|
52
|
-
common_schemas = [
|
|
53
|
-
schema for schema in payload_schemas if schema in resource_types_schemas
|
|
54
|
-
]
|
|
55
|
-
return common_schemas[0] if common_schemas else None
|
|
56
|
-
|
|
57
|
-
discriminator = Discriminator(get_schema_from_payload)
|
|
58
|
-
|
|
59
|
-
def get_tag(resource_type: type[BaseModel]) -> Tag:
|
|
60
|
-
return Tag(resource_type.model_fields["schemas"].default[0])
|
|
61
|
-
|
|
62
|
-
tagged_resources = [
|
|
63
|
-
Annotated[resource_type, get_tag(resource_type)]
|
|
64
|
-
for resource_type in resource_types
|
|
65
|
-
]
|
|
66
|
-
union = Union[tuple(tagged_resources)]
|
|
67
|
-
return Annotated[union, discriminator]
|
|
68
|
-
|
|
69
|
-
def __new__(cls, name, bases, attrs, **kwargs):
|
|
70
|
-
if kwargs.get("__pydantic_generic_metadata__") and kwargs[
|
|
71
|
-
"__pydantic_generic_metadata__"
|
|
72
|
-
].get("args"):
|
|
73
|
-
tagged_union = cls.tagged_resource_union(
|
|
74
|
-
kwargs["__pydantic_generic_metadata__"]["args"][0]
|
|
75
|
-
)
|
|
76
|
-
kwargs["__pydantic_generic_metadata__"]["args"] = (tagged_union,)
|
|
77
|
-
|
|
78
|
-
klass = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
79
|
-
return klass
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class ListResponse(Message, Generic[AnyResource], metaclass=ListResponseMetaclass):
|
|
20
|
+
class ListResponse(Message, Generic[AnyResource], metaclass=GenericMessageMetaclass):
|
|
83
21
|
schemas: Annotated[list[str], Required.true] = [
|
|
84
22
|
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
|
|
85
23
|
]
|
|
@@ -113,6 +51,7 @@ class ListResponse(Message, Generic[AnyResource], metaclass=ListResponseMetaclas
|
|
|
113
51
|
- 'resources' must be set if 'totalResults' is non-zero.
|
|
114
52
|
"""
|
|
115
53
|
obj = handler(value)
|
|
54
|
+
assert isinstance(obj, cls)
|
|
116
55
|
|
|
117
56
|
if (
|
|
118
57
|
not info.context
|
scim2_models/rfc7644/message.py
CHANGED
|
@@ -1,10 +1,110 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Callable
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from typing import Union
|
|
6
|
+
from typing import get_args
|
|
7
|
+
from typing import get_origin
|
|
8
|
+
|
|
9
|
+
from pydantic import Discriminator
|
|
10
|
+
from pydantic import Tag
|
|
11
|
+
from pydantic._internal._model_construction import ModelMetaclass
|
|
2
12
|
|
|
3
13
|
from ..base import BaseModel
|
|
4
|
-
from ..
|
|
14
|
+
from ..scim_object import ScimObject
|
|
15
|
+
from ..utils import UNION_TYPES
|
|
5
16
|
|
|
6
17
|
|
|
7
|
-
class Message(
|
|
18
|
+
class Message(ScimObject):
|
|
8
19
|
"""SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`."""
|
|
9
20
|
|
|
10
|
-
|
|
21
|
+
|
|
22
|
+
def create_schema_discriminator(
|
|
23
|
+
resource_types_schemas: list[str],
|
|
24
|
+
) -> Callable[[Any], Optional[str]]:
|
|
25
|
+
"""Create a schema discriminator function for the given resource schemas.
|
|
26
|
+
|
|
27
|
+
:param resource_types_schemas: List of valid resource schemas
|
|
28
|
+
:return: Discriminator function for Pydantic
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def get_schema_from_payload(payload: Any) -> Optional[str]:
|
|
32
|
+
"""Extract schema from SCIM payload for discrimination.
|
|
33
|
+
|
|
34
|
+
:param payload: SCIM payload dict or object
|
|
35
|
+
:return: First matching schema or None
|
|
36
|
+
"""
|
|
37
|
+
if not payload:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
payload_schemas = (
|
|
41
|
+
payload.get("schemas", []) if isinstance(payload, dict) else payload.schemas
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
common_schemas = [
|
|
45
|
+
schema for schema in payload_schemas if schema in resource_types_schemas
|
|
46
|
+
]
|
|
47
|
+
return common_schemas[0] if common_schemas else None
|
|
48
|
+
|
|
49
|
+
return get_schema_from_payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_tag(resource_type: type[BaseModel]) -> Tag:
|
|
53
|
+
"""Create Pydantic tag from resource type schema.
|
|
54
|
+
|
|
55
|
+
:param resource_type: SCIM resource type
|
|
56
|
+
:return: Pydantic Tag for discrimination
|
|
57
|
+
"""
|
|
58
|
+
return Tag(resource_type.model_fields["schemas"].default[0])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_tagged_resource_union(resource_union: Any) -> Any:
|
|
62
|
+
"""Build Discriminated Unions for SCIM resources.
|
|
63
|
+
|
|
64
|
+
Creates discriminated unions so Pydantic can determine which class to instantiate
|
|
65
|
+
by inspecting the payload's schemas field.
|
|
66
|
+
|
|
67
|
+
:param resource_union: Union type of SCIM resources
|
|
68
|
+
:return: Annotated discriminated union or original type
|
|
69
|
+
"""
|
|
70
|
+
if get_origin(resource_union) not in UNION_TYPES:
|
|
71
|
+
return resource_union
|
|
72
|
+
|
|
73
|
+
resource_types = get_args(resource_union)
|
|
74
|
+
|
|
75
|
+
# Set up schemas for the discriminator function
|
|
76
|
+
resource_types_schemas = [
|
|
77
|
+
resource_type.model_fields["schemas"].default[0]
|
|
78
|
+
for resource_type in resource_types
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
# Create discriminator function with schemas captured in closure
|
|
82
|
+
schema_discriminator = create_schema_discriminator(resource_types_schemas)
|
|
83
|
+
discriminator = Discriminator(schema_discriminator)
|
|
84
|
+
|
|
85
|
+
tagged_resources = [
|
|
86
|
+
Annotated[resource_type, get_tag(resource_type)]
|
|
87
|
+
for resource_type in resource_types
|
|
88
|
+
]
|
|
89
|
+
# Dynamic union construction from tuple - MyPy can't validate this at compile time
|
|
90
|
+
union = Union[tuple(tagged_resources)] # type: ignore
|
|
91
|
+
return Annotated[union, discriminator]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class GenericMessageMetaclass(ModelMetaclass):
|
|
95
|
+
"""Metaclass for SCIM generic types with discriminated unions."""
|
|
96
|
+
|
|
97
|
+
def __new__(
|
|
98
|
+
cls, name: str, bases: tuple[type, ...], attrs: dict[str, Any], **kwargs: Any
|
|
99
|
+
) -> type:
|
|
100
|
+
"""Create class with tagged resource unions for generic parameters."""
|
|
101
|
+
if kwargs.get("__pydantic_generic_metadata__") and kwargs[
|
|
102
|
+
"__pydantic_generic_metadata__"
|
|
103
|
+
].get("args"):
|
|
104
|
+
tagged_union = create_tagged_resource_union(
|
|
105
|
+
kwargs["__pydantic_generic_metadata__"]["args"][0]
|
|
106
|
+
)
|
|
107
|
+
kwargs["__pydantic_generic_metadata__"]["args"] = (tagged_union,)
|
|
108
|
+
|
|
109
|
+
klass = super().__new__(cls, name, bases, attrs, **kwargs)
|
|
110
|
+
return klass
|
scim2_models/rfc7644/patch_op.py
CHANGED
|
@@ -5,9 +5,11 @@ from typing import Optional
|
|
|
5
5
|
|
|
6
6
|
from pydantic import Field
|
|
7
7
|
from pydantic import field_validator
|
|
8
|
+
from pydantic import model_validator
|
|
9
|
+
from typing_extensions import Self
|
|
8
10
|
|
|
9
|
-
from ..
|
|
10
|
-
from ..
|
|
11
|
+
from ..annotations import Required
|
|
12
|
+
from ..attributes import ComplexAttribute
|
|
11
13
|
from .message import Message
|
|
12
14
|
|
|
13
15
|
|
|
@@ -17,7 +19,7 @@ class PatchOperation(ComplexAttribute):
|
|
|
17
19
|
remove = "remove"
|
|
18
20
|
add = "add"
|
|
19
21
|
|
|
20
|
-
op:
|
|
22
|
+
op: Op
|
|
21
23
|
"""Each PATCH operation object MUST have exactly one "op" member, whose
|
|
22
24
|
value indicates the operation to perform and MAY be one of "add", "remove",
|
|
23
25
|
or "replace".
|
|
@@ -32,11 +34,23 @@ class PatchOperation(ComplexAttribute):
|
|
|
32
34
|
"""The "path" attribute value is a String containing an attribute path
|
|
33
35
|
describing the target of the operation."""
|
|
34
36
|
|
|
37
|
+
@model_validator(mode="after")
|
|
38
|
+
def validate_path(self) -> Self:
|
|
39
|
+
# The "path" attribute value is a String containing an attribute path
|
|
40
|
+
# describing the target of the operation. The "path" attribute is
|
|
41
|
+
# OPTIONAL for "add" and "replace" and is REQUIRED for "remove"
|
|
42
|
+
# operations. See relevant operation sections below for details.
|
|
43
|
+
|
|
44
|
+
if self.path is None and self.op == PatchOperation.Op.remove:
|
|
45
|
+
raise ValueError("Op.path is required for remove operations")
|
|
46
|
+
|
|
47
|
+
return self
|
|
48
|
+
|
|
35
49
|
value: Optional[Any] = None
|
|
36
50
|
|
|
37
51
|
@field_validator("op", mode="before")
|
|
38
52
|
@classmethod
|
|
39
|
-
def normalize_op(cls, v):
|
|
53
|
+
def normalize_op(cls, v: Any) -> Any:
|
|
40
54
|
"""Ignorecase for op.
|
|
41
55
|
|
|
42
56
|
This brings
|
|
@@ -63,8 +77,8 @@ class PatchOp(Message):
|
|
|
63
77
|
"urn:ietf:params:scim:api:messages:2.0:PatchOp"
|
|
64
78
|
]
|
|
65
79
|
|
|
66
|
-
operations: Optional[list[PatchOperation]] = Field(
|
|
67
|
-
None, serialization_alias="Operations"
|
|
80
|
+
operations: Annotated[Optional[list[PatchOperation]], Required.true] = Field(
|
|
81
|
+
None, serialization_alias="Operations", min_length=1
|
|
68
82
|
)
|
|
69
83
|
"""The body of an HTTP PATCH request MUST contain the attribute
|
|
70
84
|
"Operations", whose value is an array of one or more PATCH operations."""
|
|
@@ -5,7 +5,7 @@ from typing import Optional
|
|
|
5
5
|
from pydantic import field_validator
|
|
6
6
|
from pydantic import model_validator
|
|
7
7
|
|
|
8
|
-
from ..
|
|
8
|
+
from ..annotations import Required
|
|
9
9
|
from .message import Message
|
|
10
10
|
|
|
11
11
|
|
|
@@ -69,7 +69,7 @@ class SearchRequest(Message):
|
|
|
69
69
|
return None if value is None else max(0, value)
|
|
70
70
|
|
|
71
71
|
@model_validator(mode="after")
|
|
72
|
-
def attributes_validator(self):
|
|
72
|
+
def attributes_validator(self) -> "SearchRequest":
|
|
73
73
|
if self.attributes and self.excluded_attributes:
|
|
74
74
|
raise ValueError(
|
|
75
75
|
"'attributes' and 'excluded_attributes' are mutually exclusive"
|
|
@@ -78,12 +78,12 @@ class SearchRequest(Message):
|
|
|
78
78
|
return self
|
|
79
79
|
|
|
80
80
|
@property
|
|
81
|
-
def start_index_0(self):
|
|
81
|
+
def start_index_0(self) -> Optional[int]:
|
|
82
82
|
"""The 0 indexed start index."""
|
|
83
83
|
return self.start_index - 1 if self.start_index is not None else None
|
|
84
84
|
|
|
85
85
|
@property
|
|
86
|
-
def stop_index_0(self):
|
|
86
|
+
def stop_index_0(self) -> Optional[int]:
|
|
87
87
|
"""The 0 indexed stop index."""
|
|
88
88
|
return (
|
|
89
89
|
self.start_index_0 + self.count
|