scim2-models 0.3.7__py3-none-any.whl → 0.4.1__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 +41 -41
- scim2_models/attributes.py +2 -2
- scim2_models/base.py +140 -42
- scim2_models/context.py +1 -1
- scim2_models/{rfc7644 → messages}/bulk.py +2 -2
- scim2_models/{rfc7644 → messages}/error.py +2 -2
- scim2_models/{rfc7644 → messages}/list_response.py +3 -3
- scim2_models/{rfc7644 → messages}/message.py +16 -7
- scim2_models/messages/patch_op.py +492 -0
- scim2_models/{rfc7644 → messages}/search_request.py +51 -2
- scim2_models/{rfc7643 → resources}/resource.py +32 -26
- scim2_models/{rfc7643 → resources}/schema.py +10 -10
- scim2_models/scim_object.py +1 -78
- scim2_models/urn.py +109 -0
- scim2_models/utils.py +107 -5
- {scim2_models-0.3.7.dist-info → scim2_models-0.4.1.dist-info}/METADATA +1 -1
- scim2_models-0.4.1.dist-info/RECORD +30 -0
- scim2_models/rfc7644/patch_op.py +0 -84
- scim2_models-0.3.7.dist-info/RECORD +0 -29
- /scim2_models/{rfc7643 → messages}/__init__.py +0 -0
- /scim2_models/{rfc7644 → resources}/__init__.py +0 -0
- /scim2_models/{rfc7643 → resources}/enterprise_user.py +0 -0
- /scim2_models/{rfc7643 → resources}/group.py +0 -0
- /scim2_models/{rfc7643 → resources}/resource_type.py +0 -0
- /scim2_models/{rfc7643 → resources}/service_provider_config.py +0 -0
- /scim2_models/{rfc7643 → resources}/user.py +0 -0
- {scim2_models-0.3.7.dist-info → scim2_models-0.4.1.dist-info}/WHEEL +0 -0
- {scim2_models-0.3.7.dist-info → scim2_models-0.4.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -28,9 +28,9 @@ from ..base import BaseModel
|
|
|
28
28
|
from ..context import Context
|
|
29
29
|
from ..reference import Reference
|
|
30
30
|
from ..scim_object import ScimObject
|
|
31
|
-
from ..
|
|
31
|
+
from ..urn import _validate_attribute_urn
|
|
32
32
|
from ..utils import UNION_TYPES
|
|
33
|
-
from ..utils import
|
|
33
|
+
from ..utils import _normalize_attribute_name
|
|
34
34
|
|
|
35
35
|
if TYPE_CHECKING:
|
|
36
36
|
from .schema import Attribute
|
|
@@ -92,14 +92,14 @@ class Extension(ScimObject):
|
|
|
92
92
|
@classmethod
|
|
93
93
|
def to_schema(cls) -> "Schema":
|
|
94
94
|
"""Build a :class:`~scim2_models.Schema` from the current extension class."""
|
|
95
|
-
return
|
|
95
|
+
return _model_to_schema(cls)
|
|
96
96
|
|
|
97
97
|
@classmethod
|
|
98
98
|
def from_schema(cls, schema: "Schema") -> type["Extension"]:
|
|
99
99
|
"""Build a :class:`~scim2_models.Extension` subclass from the schema definition."""
|
|
100
|
-
from .schema import
|
|
100
|
+
from .schema import _make_python_model
|
|
101
101
|
|
|
102
|
-
return
|
|
102
|
+
return _make_python_model(schema, cls)
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
AnyExtension = TypeVar("AnyExtension", bound="Extension")
|
|
@@ -107,7 +107,7 @@ AnyExtension = TypeVar("AnyExtension", bound="Extension")
|
|
|
107
107
|
_PARAMETERIZED_CLASSES: dict[tuple[type, tuple[Any, ...]], type] = {}
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
def
|
|
110
|
+
def _extension_serializer(
|
|
111
111
|
value: Any, handler: SerializerFunctionWrapHandler, info: SerializationInfo
|
|
112
112
|
) -> Optional[dict[str, Any]]:
|
|
113
113
|
"""Exclude the Resource attributes from the extension dump.
|
|
@@ -184,13 +184,13 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
184
184
|
class_attrs[extension.__name__] = Field(
|
|
185
185
|
default=None, # type: ignore[arg-type]
|
|
186
186
|
serialization_alias=schema,
|
|
187
|
-
validation_alias=
|
|
187
|
+
validation_alias=_normalize_attribute_name(schema),
|
|
188
188
|
)
|
|
189
189
|
|
|
190
190
|
new_annotations = {
|
|
191
191
|
extension.__name__: Annotated[
|
|
192
192
|
Optional[extension],
|
|
193
|
-
WrapSerializer(
|
|
193
|
+
WrapSerializer(_extension_serializer),
|
|
194
194
|
]
|
|
195
195
|
for extension in valid_extensions
|
|
196
196
|
}
|
|
@@ -288,14 +288,14 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
288
288
|
@classmethod
|
|
289
289
|
def to_schema(cls) -> "Schema":
|
|
290
290
|
"""Build a :class:`~scim2_models.Schema` from the current resource class."""
|
|
291
|
-
return
|
|
291
|
+
return _model_to_schema(cls)
|
|
292
292
|
|
|
293
293
|
@classmethod
|
|
294
294
|
def from_schema(cls, schema: "Schema") -> type["Resource"]:
|
|
295
295
|
"""Build a :class:`scim2_models.Resource` subclass from the schema definition."""
|
|
296
|
-
from .schema import
|
|
296
|
+
from .schema import _make_python_model
|
|
297
297
|
|
|
298
|
-
return
|
|
298
|
+
return _make_python_model(schema, cls)
|
|
299
299
|
|
|
300
300
|
def _prepare_model_dump(
|
|
301
301
|
self,
|
|
@@ -305,13 +305,19 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
305
305
|
**kwargs: Any,
|
|
306
306
|
) -> dict[str, Any]:
|
|
307
307
|
kwargs = super()._prepare_model_dump(scim_ctx, **kwargs)
|
|
308
|
+
|
|
309
|
+
# RFC 7644: "SHOULD ignore any query parameters they do not recognize"
|
|
308
310
|
kwargs["context"]["scim_attributes"] = [
|
|
309
|
-
|
|
311
|
+
valid_attr
|
|
310
312
|
for attribute in (attributes or [])
|
|
313
|
+
if (valid_attr := _validate_attribute_urn(attribute, self.__class__))
|
|
314
|
+
is not None
|
|
311
315
|
]
|
|
312
316
|
kwargs["context"]["scim_excluded_attributes"] = [
|
|
313
|
-
|
|
317
|
+
valid_attr
|
|
314
318
|
for attribute in (excluded_attributes or [])
|
|
319
|
+
if (valid_attr := _validate_attribute_urn(attribute, self.__class__))
|
|
320
|
+
is not None
|
|
315
321
|
]
|
|
316
322
|
return kwargs
|
|
317
323
|
|
|
@@ -330,9 +336,9 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
330
336
|
messages. Pass :data:`None` to get the default Pydantic behavior.
|
|
331
337
|
:param attributes: A multi-valued list of strings indicating the names of resource
|
|
332
338
|
attributes to return in the response, overriding the set of attributes that
|
|
333
|
-
would be returned by default.
|
|
339
|
+
would be returned by default. Invalid values are ignored.
|
|
334
340
|
:param excluded_attributes: A multi-valued list of strings indicating the names of resource
|
|
335
|
-
attributes to be removed from the default set of attributes to return.
|
|
341
|
+
attributes to be removed from the default set of attributes to return. Invalid values are ignored.
|
|
336
342
|
"""
|
|
337
343
|
dump_kwargs = self._prepare_model_dump(
|
|
338
344
|
scim_ctx, attributes, excluded_attributes, **kwargs
|
|
@@ -356,9 +362,9 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
356
362
|
messages. Pass :data:`None` to get the default Pydantic behavior.
|
|
357
363
|
:param attributes: A multi-valued list of strings indicating the names of resource
|
|
358
364
|
attributes to return in the response, overriding the set of attributes that
|
|
359
|
-
would be returned by default.
|
|
365
|
+
would be returned by default. Invalid values are ignored.
|
|
360
366
|
:param excluded_attributes: A multi-valued list of strings indicating the names of resource
|
|
361
|
-
attributes to be removed from the default set of attributes to return.
|
|
367
|
+
attributes to be removed from the default set of attributes to return. Invalid values are ignored.
|
|
362
368
|
"""
|
|
363
369
|
dump_kwargs = self._prepare_model_dump(
|
|
364
370
|
scim_ctx, attributes, excluded_attributes, **kwargs
|
|
@@ -369,7 +375,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
|
|
|
369
375
|
AnyResource = TypeVar("AnyResource", bound="Resource")
|
|
370
376
|
|
|
371
377
|
|
|
372
|
-
def
|
|
378
|
+
def _dedicated_attributes(
|
|
373
379
|
model: type[BaseModel], excluded_models: list[type[BaseModel]]
|
|
374
380
|
) -> dict[str, Any]:
|
|
375
381
|
"""Return attributes that are not members the parent 'excluded_models'."""
|
|
@@ -397,13 +403,13 @@ def dedicated_attributes(
|
|
|
397
403
|
return field_infos
|
|
398
404
|
|
|
399
405
|
|
|
400
|
-
def
|
|
401
|
-
from scim2_models.
|
|
406
|
+
def _model_to_schema(model: type[BaseModel]) -> "Schema":
|
|
407
|
+
from scim2_models.resources.schema import Schema
|
|
402
408
|
|
|
403
409
|
schema_urn = model.model_fields["schemas"].default[0]
|
|
404
|
-
field_infos =
|
|
410
|
+
field_infos = _dedicated_attributes(model, [Resource])
|
|
405
411
|
attributes = [
|
|
406
|
-
|
|
412
|
+
_model_attribute_to_scim_attribute(model, attribute_name)
|
|
407
413
|
for attribute_name in field_infos
|
|
408
414
|
if attribute_name != "schemas"
|
|
409
415
|
]
|
|
@@ -416,10 +422,10 @@ def model_to_schema(model: type[BaseModel]) -> "Schema":
|
|
|
416
422
|
return schema
|
|
417
423
|
|
|
418
424
|
|
|
419
|
-
def
|
|
425
|
+
def _model_attribute_to_scim_attribute(
|
|
420
426
|
model: type[BaseModel], attribute_name: str
|
|
421
427
|
) -> "Attribute":
|
|
422
|
-
from scim2_models.
|
|
428
|
+
from scim2_models.resources.schema import Attribute
|
|
423
429
|
|
|
424
430
|
field_info = model.model_fields[attribute_name]
|
|
425
431
|
root_type = model.get_field_root_type(attribute_name)
|
|
@@ -430,8 +436,8 @@ def model_attribute_to_scim_attribute(
|
|
|
430
436
|
attribute_type = Attribute.Type.from_python(root_type)
|
|
431
437
|
sub_attributes = (
|
|
432
438
|
[
|
|
433
|
-
|
|
434
|
-
for sub_attribute_name in
|
|
439
|
+
_model_attribute_to_scim_attribute(root_type, sub_attribute_name)
|
|
440
|
+
for sub_attribute_name in _dedicated_attributes(
|
|
435
441
|
root_type,
|
|
436
442
|
[MultiValuedComplexAttribute],
|
|
437
443
|
)
|
|
@@ -31,13 +31,13 @@ from ..reference import ExternalReference
|
|
|
31
31
|
from ..reference import Reference
|
|
32
32
|
from ..reference import URIReference
|
|
33
33
|
from ..utils import Base64Bytes
|
|
34
|
-
from ..utils import
|
|
34
|
+
from ..utils import _normalize_attribute_name
|
|
35
35
|
from .resource import Resource
|
|
36
36
|
|
|
37
37
|
T = TypeVar("T", bound=BaseModel)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def _make_python_identifier(identifier: str) -> str:
|
|
41
41
|
"""Sanitize string to be a suitable Python/Pydantic class attribute name."""
|
|
42
42
|
sanitized = re.sub(r"\W|^(?=\d)", "", identifier)
|
|
43
43
|
if sanitized in RESERVED_WORDS:
|
|
@@ -46,7 +46,7 @@ def make_python_identifier(identifier: str) -> str:
|
|
|
46
46
|
return sanitized
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def
|
|
49
|
+
def _make_python_model(
|
|
50
50
|
obj: Union["Schema", "Attribute"],
|
|
51
51
|
base: type[T],
|
|
52
52
|
multiple: bool = False,
|
|
@@ -54,14 +54,14 @@ def make_python_model(
|
|
|
54
54
|
"""Build a Python model from a Schema or an Attribute object."""
|
|
55
55
|
if isinstance(obj, Attribute):
|
|
56
56
|
pydantic_attributes = {
|
|
57
|
-
to_snake(
|
|
57
|
+
to_snake(_make_python_identifier(attr.name)): attr._to_python()
|
|
58
58
|
for attr in (obj.sub_attributes or [])
|
|
59
59
|
if attr.name
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
else:
|
|
63
63
|
pydantic_attributes = {
|
|
64
|
-
to_snake(
|
|
64
|
+
to_snake(_make_python_identifier(attr.name)): attr._to_python()
|
|
65
65
|
for attr in (obj.attributes or [])
|
|
66
66
|
if attr.name
|
|
67
67
|
}
|
|
@@ -97,7 +97,7 @@ class Attribute(ComplexAttribute):
|
|
|
97
97
|
reference = "reference"
|
|
98
98
|
binary = "binary"
|
|
99
99
|
|
|
100
|
-
def
|
|
100
|
+
def _to_python(
|
|
101
101
|
self,
|
|
102
102
|
multiple: bool = False,
|
|
103
103
|
reference_types: Optional[list[str]] = None,
|
|
@@ -210,15 +210,15 @@ class Attribute(ComplexAttribute):
|
|
|
210
210
|
"""When an attribute is of type "complex", "subAttributes" defines a set of
|
|
211
211
|
sub-attributes."""
|
|
212
212
|
|
|
213
|
-
def
|
|
213
|
+
def _to_python(self) -> Optional[tuple[Any, Any]]:
|
|
214
214
|
"""Build tuple suited to be passed to pydantic 'create_model'."""
|
|
215
215
|
if not self.name or not self.type:
|
|
216
216
|
return None
|
|
217
217
|
|
|
218
|
-
attr_type = self.type.
|
|
218
|
+
attr_type = self.type._to_python(bool(self.multi_valued), self.reference_types)
|
|
219
219
|
|
|
220
220
|
if attr_type in (ComplexAttribute, MultiValuedComplexAttribute):
|
|
221
|
-
attr_type =
|
|
221
|
+
attr_type = _make_python_model(
|
|
222
222
|
obj=self, base=attr_type, multiple=bool(self.multi_valued)
|
|
223
223
|
)
|
|
224
224
|
|
|
@@ -238,7 +238,7 @@ class Attribute(ComplexAttribute):
|
|
|
238
238
|
description=self.description,
|
|
239
239
|
examples=self.canonical_values,
|
|
240
240
|
serialization_alias=self.name,
|
|
241
|
-
validation_alias=
|
|
241
|
+
validation_alias=_normalize_attribute_name(self.name),
|
|
242
242
|
default=None,
|
|
243
243
|
)
|
|
244
244
|
|
scim2_models/scim_object.py
CHANGED
|
@@ -8,86 +8,9 @@ from typing import Optional
|
|
|
8
8
|
from .annotations import Required
|
|
9
9
|
from .base import BaseModel
|
|
10
10
|
from .context import Context
|
|
11
|
-
from .utils import normalize_attribute_name
|
|
12
11
|
|
|
13
12
|
if TYPE_CHECKING:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
|
|
18
|
-
"""Validate that an attribute name or a sub-attribute path exist for a given model."""
|
|
19
|
-
attribute_name, *sub_attribute_blocks = attribute_base.split(".")
|
|
20
|
-
sub_attribute_base = ".".join(sub_attribute_blocks)
|
|
21
|
-
|
|
22
|
-
aliases = {field.validation_alias for field in model.model_fields.values()}
|
|
23
|
-
|
|
24
|
-
if normalize_attribute_name(attribute_name) not in aliases:
|
|
25
|
-
raise ValueError(
|
|
26
|
-
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
if sub_attribute_base:
|
|
30
|
-
attribute_type = model.get_field_root_type(attribute_name)
|
|
31
|
-
|
|
32
|
-
if not attribute_type or not issubclass(attribute_type, BaseModel):
|
|
33
|
-
raise ValueError(
|
|
34
|
-
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
validate_model_attribute(attribute_type, sub_attribute_base)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def extract_schema_and_attribute_base(attribute_urn: str) -> tuple[str, str]:
|
|
41
|
-
"""Extract the schema urn part and the attribute name part from attribute name.
|
|
42
|
-
|
|
43
|
-
As defined in :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
|
|
44
|
-
"""
|
|
45
|
-
*urn_blocks, attribute_base = attribute_urn.split(":")
|
|
46
|
-
schema = ":".join(urn_blocks)
|
|
47
|
-
return schema, attribute_base
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def validate_attribute_urn(
|
|
51
|
-
attribute_name: str,
|
|
52
|
-
default_resource: Optional[type["Resource"]] = None,
|
|
53
|
-
resource_types: Optional[list[type["Resource"]]] = None,
|
|
54
|
-
) -> str:
|
|
55
|
-
"""Validate that an attribute urn is valid or not.
|
|
56
|
-
|
|
57
|
-
:param attribute_name: The attribute urn to check.
|
|
58
|
-
:default_resource: The default resource if `attribute_name` is not an absolute urn.
|
|
59
|
-
:resource_types: The available resources in which to look for the attribute.
|
|
60
|
-
:return: The normalized attribute URN.
|
|
61
|
-
"""
|
|
62
|
-
from .rfc7643.resource import Resource
|
|
63
|
-
|
|
64
|
-
if not resource_types:
|
|
65
|
-
resource_types = []
|
|
66
|
-
|
|
67
|
-
if default_resource and default_resource not in resource_types:
|
|
68
|
-
resource_types.append(default_resource)
|
|
69
|
-
|
|
70
|
-
default_schema = (
|
|
71
|
-
default_resource.model_fields["schemas"].default[0]
|
|
72
|
-
if default_resource
|
|
73
|
-
else None
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
schema: Optional[Any]
|
|
77
|
-
schema, attribute_base = extract_schema_and_attribute_base(attribute_name)
|
|
78
|
-
if not schema:
|
|
79
|
-
schema = default_schema
|
|
80
|
-
|
|
81
|
-
if not schema:
|
|
82
|
-
raise ValueError("No default schema and relative URN")
|
|
83
|
-
|
|
84
|
-
resource = Resource.get_by_schema(resource_types, schema)
|
|
85
|
-
if not resource:
|
|
86
|
-
raise ValueError(f"No resource matching schema '{schema}'")
|
|
87
|
-
|
|
88
|
-
validate_model_attribute(resource, attribute_base)
|
|
89
|
-
|
|
90
|
-
return f"{schema}:{attribute_base}"
|
|
13
|
+
pass
|
|
91
14
|
|
|
92
15
|
|
|
93
16
|
class ScimObject(BaseModel):
|
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
|
@@ -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.1
|
|
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=yT_5gDcXWeSOdxvpYrdHh5nD2uD-B9kdfQ5qjC-JUkY,19726
|
|
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.1.dist-info/METADATA,sha256=zu1Txl8FZcnVF6WYYkBl5g7O1rvfcnaG1Dk987DtIRM,16288
|
|
28
|
+
scim2_models-0.4.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
29
|
+
scim2_models-0.4.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
30
|
+
scim2_models-0.4.1.dist-info/RECORD,,
|