scim2-models 0.3.6__py3-none-any.whl → 0.4.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 +52 -52
- scim2_models/annotations.py +104 -0
- scim2_models/attributes.py +57 -0
- scim2_models/base.py +195 -554
- scim2_models/constants.py +3 -3
- scim2_models/context.py +168 -0
- scim2_models/{rfc7644 → messages}/bulk.py +4 -4
- scim2_models/{rfc7644 → messages}/error.py +13 -13
- scim2_models/messages/list_response.py +75 -0
- scim2_models/messages/message.py +119 -0
- scim2_models/messages/patch_op.py +478 -0
- scim2_models/{rfc7644 → messages}/search_request.py +55 -6
- scim2_models/reference.py +82 -0
- scim2_models/{rfc7643 → resources}/enterprise_user.py +4 -4
- scim2_models/{rfc7643 → resources}/group.py +5 -5
- scim2_models/resources/resource.py +468 -0
- scim2_models/{rfc7643 → resources}/resource_type.py +13 -22
- scim2_models/{rfc7643 → resources}/schema.py +51 -45
- scim2_models/{rfc7643 → resources}/service_provider_config.py +7 -7
- scim2_models/{rfc7643 → resources}/user.py +9 -9
- scim2_models/scim_object.py +66 -0
- scim2_models/urn.py +109 -0
- scim2_models/utils.py +108 -6
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/METADATA +1 -1
- scim2_models-0.4.0.dist-info/RECORD +30 -0
- scim2_models/rfc7643/resource.py +0 -355
- scim2_models/rfc7644/list_response.py +0 -136
- scim2_models/rfc7644/message.py +0 -10
- scim2_models/rfc7644/patch_op.py +0 -70
- scim2_models-0.3.6.dist-info/RECORD +0 -24
- /scim2_models/{rfc7643 → messages}/__init__.py +0 -0
- /scim2_models/{rfc7644 → resources}/__init__.py +0 -0
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/WHEEL +0 -0
- {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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,26 +17,27 @@ 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
|
-
from ..utils import
|
|
34
|
-
from .resource import Extension
|
|
34
|
+
from ..utils import _normalize_attribute_name
|
|
35
35
|
from .resource import Resource
|
|
36
36
|
|
|
37
|
+
T = TypeVar("T", bound=BaseModel)
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
|
|
40
|
+
def _make_python_identifier(identifier: str) -> str:
|
|
39
41
|
"""Sanitize string to be a suitable Python/Pydantic class attribute name."""
|
|
40
42
|
sanitized = re.sub(r"\W|^(?=\d)", "", identifier)
|
|
41
43
|
if sanitized in RESERVED_WORDS:
|
|
@@ -44,23 +46,22 @@ def make_python_identifier(identifier: str) -> str:
|
|
|
44
46
|
return sanitized
|
|
45
47
|
|
|
46
48
|
|
|
47
|
-
def
|
|
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 = {
|
|
55
|
-
to_snake(
|
|
57
|
+
to_snake(_make_python_identifier(attr.name)): attr._to_python()
|
|
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 = {
|
|
63
|
-
to_snake(
|
|
64
|
+
to_snake(_make_python_identifier(attr.name)): attr._to_python()
|
|
64
65
|
for attr in (obj.attributes or [])
|
|
65
66
|
if attr.name
|
|
66
67
|
}
|
|
@@ -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
|
|
@@ -93,9 +97,9 @@ class Attribute(ComplexAttribute):
|
|
|
93
97
|
reference = "reference"
|
|
94
98
|
binary = "binary"
|
|
95
99
|
|
|
96
|
-
def
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
@@ -232,7 +238,7 @@ class Attribute(ComplexAttribute):
|
|
|
232
238
|
description=self.description,
|
|
233
239
|
examples=self.canonical_values,
|
|
234
240
|
serialization_alias=self.name,
|
|
235
|
-
validation_alias=
|
|
241
|
+
validation_alias=_normalize_attribute_name(self.name),
|
|
236
242
|
default=None,
|
|
237
243
|
)
|
|
238
244
|
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Base SCIM object classes with schema identification."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .annotations import Required
|
|
9
|
+
from .base import BaseModel
|
|
10
|
+
from .context import Context
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ScimObject(BaseModel):
|
|
17
|
+
schemas: Annotated[list[str], Required.true]
|
|
18
|
+
"""The "schemas" attribute is a REQUIRED attribute and is an array of
|
|
19
|
+
Strings containing URIs that are used to indicate the namespaces of the
|
|
20
|
+
SCIM schemas that define the attributes present in the current JSON
|
|
21
|
+
structure."""
|
|
22
|
+
|
|
23
|
+
def _prepare_model_dump(
|
|
24
|
+
self,
|
|
25
|
+
scim_ctx: Optional[Context] = Context.DEFAULT,
|
|
26
|
+
**kwargs: Any,
|
|
27
|
+
) -> dict[str, Any]:
|
|
28
|
+
kwargs.setdefault("context", {}).setdefault("scim", scim_ctx)
|
|
29
|
+
|
|
30
|
+
if scim_ctx:
|
|
31
|
+
kwargs.setdefault("exclude_none", True)
|
|
32
|
+
kwargs.setdefault("by_alias", True)
|
|
33
|
+
|
|
34
|
+
return kwargs
|
|
35
|
+
|
|
36
|
+
def model_dump(
|
|
37
|
+
self,
|
|
38
|
+
*args: Any,
|
|
39
|
+
scim_ctx: Optional[Context] = Context.DEFAULT,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
|
|
43
|
+
|
|
44
|
+
:param scim_ctx: If a SCIM context is passed, some default values of
|
|
45
|
+
Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
|
|
46
|
+
messages. Pass :data:`None` to get the default Pydantic behavior.
|
|
47
|
+
"""
|
|
48
|
+
dump_kwargs = self._prepare_model_dump(scim_ctx, **kwargs)
|
|
49
|
+
if scim_ctx:
|
|
50
|
+
dump_kwargs.setdefault("mode", "json")
|
|
51
|
+
return super(BaseModel, self).model_dump(*args, **dump_kwargs)
|
|
52
|
+
|
|
53
|
+
def model_dump_json(
|
|
54
|
+
self,
|
|
55
|
+
*args: Any,
|
|
56
|
+
scim_ctx: Optional[Context] = Context.DEFAULT,
|
|
57
|
+
**kwargs: Any,
|
|
58
|
+
) -> str:
|
|
59
|
+
"""Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
|
|
60
|
+
|
|
61
|
+
:param scim_ctx: If a SCIM context is passed, some default values of
|
|
62
|
+
Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM
|
|
63
|
+
messages. Pass :data:`None` to get the default Pydantic behavior.
|
|
64
|
+
"""
|
|
65
|
+
dump_kwargs = self._prepare_model_dump(scim_ctx, **kwargs)
|
|
66
|
+
return super(BaseModel, self).model_dump_json(*args, **dump_kwargs)
|
scim2_models/urn.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from .base import BaseModel
|
|
6
|
+
from .utils import _normalize_attribute_name
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .base import BaseModel
|
|
10
|
+
from .resources.resource import Resource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_or_create_extension_instance(
|
|
14
|
+
model: "Resource", extension_class: type
|
|
15
|
+
) -> "BaseModel":
|
|
16
|
+
"""Get existing extension instance or create a new one."""
|
|
17
|
+
extension_instance = model[extension_class]
|
|
18
|
+
if extension_instance is None:
|
|
19
|
+
extension_instance = extension_class()
|
|
20
|
+
model[extension_class] = extension_instance
|
|
21
|
+
return extension_instance
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_path(model: Optional[type["BaseModel"]], path: str) -> tuple[str, str]:
|
|
25
|
+
"""Resolve a path to (schema_urn, attribute_path)."""
|
|
26
|
+
from .resources.resource import Resource
|
|
27
|
+
|
|
28
|
+
# Absolute URN
|
|
29
|
+
if ":" in path:
|
|
30
|
+
parts = path.rsplit(":", 1)
|
|
31
|
+
return parts[0], parts[1]
|
|
32
|
+
|
|
33
|
+
# Relative URN with a schema
|
|
34
|
+
elif model and issubclass(model, Resource) and hasattr(model, "model_fields"):
|
|
35
|
+
schemas_field = model.model_fields.get("schemas")
|
|
36
|
+
return schemas_field.default[0], path # type: ignore
|
|
37
|
+
|
|
38
|
+
return "", path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
|
|
42
|
+
"""Validate that an attribute name or a sub-attribute path exist for a given model."""
|
|
43
|
+
attribute_name, *sub_attribute_blocks = attribute_base.split(".")
|
|
44
|
+
sub_attribute_base = ".".join(sub_attribute_blocks)
|
|
45
|
+
|
|
46
|
+
aliases = {field.validation_alias for field in model.model_fields.values()}
|
|
47
|
+
|
|
48
|
+
if _normalize_attribute_name(attribute_name) not in aliases:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if sub_attribute_base:
|
|
54
|
+
attribute_type = model.get_field_root_type(attribute_name)
|
|
55
|
+
|
|
56
|
+
if not attribute_type or not issubclass(attribute_type, BaseModel):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_validate_model_attribute(attribute_type, sub_attribute_base)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _validate_attribute_urn(
|
|
65
|
+
attribute_name: str, resource: type["Resource"]
|
|
66
|
+
) -> Optional[str]:
|
|
67
|
+
"""Validate that an attribute urn is valid or not.
|
|
68
|
+
|
|
69
|
+
:param attribute_name: The attribute urn to check.
|
|
70
|
+
:return: The normalized attribute URN.
|
|
71
|
+
"""
|
|
72
|
+
from .resources.resource import Resource
|
|
73
|
+
|
|
74
|
+
schema: Optional[Any]
|
|
75
|
+
schema, attribute_base = _normalize_path(resource, attribute_name)
|
|
76
|
+
|
|
77
|
+
validated_resource = Resource.get_by_schema([resource], schema)
|
|
78
|
+
if not validated_resource:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
_validate_model_attribute(validated_resource, attribute_base)
|
|
83
|
+
except ValueError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
return f"{schema}:{attribute_base}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve_path_to_target(
|
|
90
|
+
resource: "Resource", path: str
|
|
91
|
+
) -> tuple[Optional["BaseModel"], str]:
|
|
92
|
+
"""Resolve a path to a target and an attribute_path.
|
|
93
|
+
|
|
94
|
+
The target can be the resource itself, or an extension object.
|
|
95
|
+
"""
|
|
96
|
+
schema_urn, attr_path = _normalize_path(type(resource), path)
|
|
97
|
+
|
|
98
|
+
if not schema_urn:
|
|
99
|
+
return resource, attr_path
|
|
100
|
+
|
|
101
|
+
if schema_urn in resource.schemas:
|
|
102
|
+
return resource, attr_path
|
|
103
|
+
|
|
104
|
+
extension_class = resource.get_extension_model(schema_urn)
|
|
105
|
+
if not extension_class:
|
|
106
|
+
return (None, "")
|
|
107
|
+
|
|
108
|
+
extension_instance = _get_or_create_extension_instance(resource, extension_class)
|
|
109
|
+
return extension_instance, attr_path
|
scim2_models/utils.py
CHANGED
|
@@ -11,7 +11,7 @@ from pydantic.alias_generators import to_snake
|
|
|
11
11
|
from pydantic_core import PydanticCustomError
|
|
12
12
|
|
|
13
13
|
try:
|
|
14
|
-
from types import UnionType
|
|
14
|
+
from types import UnionType
|
|
15
15
|
|
|
16
16
|
UNION_TYPES = [Union, UnionType]
|
|
17
17
|
except ImportError:
|
|
@@ -19,12 +19,12 @@ except ImportError:
|
|
|
19
19
|
UNION_TYPES = [Union]
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def
|
|
22
|
+
def _int_to_str(status: Optional[int]) -> Optional[str]:
|
|
23
23
|
return None if status is None else str(status)
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
# Copied from Pydantic 2.10 repository
|
|
27
|
-
class
|
|
27
|
+
class _Base64Encoder(EncoderProtocol): # pragma: no cover
|
|
28
28
|
"""Standard (non-URL-safe) Base64 encoder."""
|
|
29
29
|
|
|
30
30
|
@classmethod
|
|
@@ -71,10 +71,10 @@ class Base64Encoder(EncoderProtocol): # pragma: no cover
|
|
|
71
71
|
|
|
72
72
|
# Compatibility with Pydantic <2.10
|
|
73
73
|
# https://pydantic.dev/articles/pydantic-v2-10-release#use-b64decode-and-b64encode-for-base64bytes-and-base64str-types
|
|
74
|
-
Base64Bytes = Annotated[bytes, EncodedBytes(encoder=
|
|
74
|
+
Base64Bytes = Annotated[bytes, EncodedBytes(encoder=_Base64Encoder)]
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def
|
|
77
|
+
def _to_camel(string: str) -> str:
|
|
78
78
|
"""Transform strings to camelCase.
|
|
79
79
|
|
|
80
80
|
This method is used for attribute name serialization. This is more
|
|
@@ -87,7 +87,7 @@ def to_camel(string: str) -> str:
|
|
|
87
87
|
return camel
|
|
88
88
|
|
|
89
89
|
|
|
90
|
-
def
|
|
90
|
+
def _normalize_attribute_name(attribute_name: str) -> str:
|
|
91
91
|
"""Remove all non-alphabetical characters and lowerise a string.
|
|
92
92
|
|
|
93
93
|
This method is used for attribute name validation.
|
|
@@ -97,3 +97,105 @@ def normalize_attribute_name(attribute_name: str) -> str:
|
|
|
97
97
|
attribute_name = re.sub(r"[\W_]+", "", attribute_name)
|
|
98
98
|
|
|
99
99
|
return attribute_name.lower()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_scim_path_syntax(path: str) -> bool:
|
|
103
|
+
"""Check if path syntax is valid according to RFC 7644 simplified rules.
|
|
104
|
+
|
|
105
|
+
:param path: The path to validate
|
|
106
|
+
:type path: str
|
|
107
|
+
:return: True if path syntax is valid, False otherwise
|
|
108
|
+
:rtype: bool
|
|
109
|
+
"""
|
|
110
|
+
if not path or not path.strip():
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
# Cannot start with a digit
|
|
114
|
+
if path[0].isdigit():
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Cannot contain double dots
|
|
118
|
+
if ".." in path:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
# Cannot contain invalid characters (basic check)
|
|
122
|
+
# Allow alphanumeric, dots, underscores, hyphens, colons (for URNs), brackets
|
|
123
|
+
if not re.match(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$', path):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
# If it contains a colon, validate it's a proper URN format
|
|
127
|
+
if ":" in path:
|
|
128
|
+
if not _validate_scim_urn_syntax(path):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _validate_scim_urn_syntax(path: str) -> bool:
|
|
135
|
+
"""Validate URN-based path format.
|
|
136
|
+
|
|
137
|
+
:param path: The URN path to validate
|
|
138
|
+
:type path: str
|
|
139
|
+
:return: True if URN path format is valid, False otherwise
|
|
140
|
+
:rtype: bool
|
|
141
|
+
"""
|
|
142
|
+
# Basic URN validation: should start with urn:
|
|
143
|
+
if not path.startswith("urn:"):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
# Split on the last colon to separate URN from attribute
|
|
147
|
+
urn_part, attr_part = path.rsplit(":", 1)
|
|
148
|
+
|
|
149
|
+
# URN part should have at least 4 parts (urn:namespace:specific:resource)
|
|
150
|
+
urn_segments = urn_part.split(":")
|
|
151
|
+
if len(urn_segments) < 4:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
# Attribute part should be valid
|
|
155
|
+
if not attr_part or attr_part[0].isdigit():
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _extract_field_name(path: str) -> Optional[str]:
|
|
162
|
+
"""Extract the field name from a path.
|
|
163
|
+
|
|
164
|
+
For now, only handle simple paths (no filters, no complex expressions).
|
|
165
|
+
Returns None for complex paths that require filter parsing.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
# Handle URN paths
|
|
169
|
+
if path.startswith("urn:"):
|
|
170
|
+
# First validate it's a proper URN
|
|
171
|
+
if not _validate_scim_urn_syntax(path):
|
|
172
|
+
return None
|
|
173
|
+
parts = path.rsplit(":", 1)
|
|
174
|
+
return parts[1]
|
|
175
|
+
|
|
176
|
+
# Simple attribute path (may have dots for sub-attributes)
|
|
177
|
+
# For now, just take the first part before any dot
|
|
178
|
+
if "." in path:
|
|
179
|
+
return path.split(".")[0]
|
|
180
|
+
|
|
181
|
+
return path
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _find_field_name(resource_class, attr_name: str) -> Optional[str]:
|
|
185
|
+
"""Find the actual field name in a resource class from an attribute name.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
resource_class: The resource class to search in
|
|
189
|
+
attr_name: The attribute name to find (e.g., "nickName")
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
The actual field name if found (e.g., "nick_name"), None otherwise
|
|
193
|
+
|
|
194
|
+
"""
|
|
195
|
+
normalized_attr_name = _normalize_attribute_name(attr_name)
|
|
196
|
+
|
|
197
|
+
for field_key in resource_class.model_fields:
|
|
198
|
+
if _normalize_attribute_name(field_key) == normalized_attr_name:
|
|
199
|
+
return field_key
|
|
200
|
+
|
|
201
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scim2-models
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: SCIM2 models serialization and validation with pydantic
|
|
5
5
|
Project-URL: documentation, https://scim2-models.readthedocs.io
|
|
6
6
|
Project-URL: repository, https://github.com/python-scim/scim2-models
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
scim2_models/__init__.py,sha256=IACL_c94UhKq0ZEXOb3LMZgeEQMfQhdOVpWGDnHSpXo,3201
|
|
2
|
+
scim2_models/annotations.py,sha256=oRjlKL1fqrYfa9UtaMdxF5fOT8CUUN3m-rdzvf7aiSA,3304
|
|
3
|
+
scim2_models/attributes.py,sha256=REp_WMTxs02NDcJJrSCbplCqnhVtExvfzozp_JJ_BdY,1785
|
|
4
|
+
scim2_models/base.py,sha256=8Nh-R8vN_Gd9_NU9Oq-heD5h2MzjT_X9djidG3OeO0g,20389
|
|
5
|
+
scim2_models/constants.py,sha256=9egq8JW0dFAqPng85CiHoH5T6pRtYL87-gC0C-IMGsk,573
|
|
6
|
+
scim2_models/context.py,sha256=RjgMIvWPr8f41qbVL1sjaDnm9GRKyrCrgfC4npwwcMg,9149
|
|
7
|
+
scim2_models/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
scim2_models/reference.py,sha256=EQM8bbSr_kxbFMNlWYf_4sAJlSsOS5wUrn-9_eF0Ykc,2483
|
|
9
|
+
scim2_models/scim_object.py,sha256=6a-lf8iIQhlMY7lCM7RcSde4f1kECncE0Ae8uJ4RaaA,2404
|
|
10
|
+
scim2_models/urn.py,sha256=n94gnOBMJguXfyczcFvkn8ipvZuRXoxYPC7QeXe9HoY,3559
|
|
11
|
+
scim2_models/utils.py,sha256=3yvU261wl5IR-zPcNhcNcQFwCd0h--k07mfTSgUY1nE,5681
|
|
12
|
+
scim2_models/messages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
scim2_models/messages/bulk.py,sha256=snPB722V_Msg1JBFcaTeEW_sa-jt-KHv6fh071NoKCg,2592
|
|
14
|
+
scim2_models/messages/error.py,sha256=_IHUoFwl-QJiICIcRkhaXf_9C7RXgiX7TL_26QXDo2c,6304
|
|
15
|
+
scim2_models/messages/list_response.py,sha256=B-XELBTks6I45pKheAS5Go9x5IDKhrLJWsqC8y3G00s,2400
|
|
16
|
+
scim2_models/messages/message.py,sha256=sjo_iFMJelZkssvA4O5xTqIt-urDxUbSP6N_WsnZ63Y,4081
|
|
17
|
+
scim2_models/messages/patch_op.py,sha256=yM13Jtk9c1dJJ3ci9dE5kwXqruFb1Lm0rrIs6nct3YE,19050
|
|
18
|
+
scim2_models/messages/search_request.py,sha256=BDTDzvtfCuFZDFsZBHl8ys8Te02jLuvGaTNipBQByrM,4632
|
|
19
|
+
scim2_models/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
scim2_models/resources/enterprise_user.py,sha256=TVa5aS-eLHcDkwyr58hZYsRKk0AwjUaUaSFhU51mn5E,1806
|
|
21
|
+
scim2_models/resources/group.py,sha256=lJXKopa__LoWhkaNqu0JFVyQaOMe-AF4vISaq0Gg7cE,1458
|
|
22
|
+
scim2_models/resources/resource.py,sha256=GZaV_bi6r2A_UfPSQgEb3jGEeEqMhw2YGImRIhfKOY0,17615
|
|
23
|
+
scim2_models/resources/resource_type.py,sha256=scLqbD3HX3fXT2JOXY9OUVnZz7i5ty2lx4VuiVxt6DE,3314
|
|
24
|
+
scim2_models/resources/schema.py,sha256=KBqDRr2sgLrqL8UYWon-uxBzIA0118srJ8tXobs950E,10659
|
|
25
|
+
scim2_models/resources/service_provider_config.py,sha256=6xJ182T-1szEQnN5Zb1cTdQCgTYIFi4XKygbvDlTKTM,5446
|
|
26
|
+
scim2_models/resources/user.py,sha256=ErOghhilUF7fipwDRqARyLwJhbntQx4GJG3u2sZNJXs,10664
|
|
27
|
+
scim2_models-0.4.0.dist-info/METADATA,sha256=PNXZC9wPGqbZqpDMQUh6WcRhyLy1EFmchdVorOH1dgc,16288
|
|
28
|
+
scim2_models-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
29
|
+
scim2_models-0.4.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
30
|
+
scim2_models-0.4.0.dist-info/RECORD,,
|