scim2-models 0.5.1__py3-none-any.whl → 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scim2_models/__init__.py +32 -0
- scim2_models/attributes.py +5 -1
- scim2_models/base.py +68 -17
- scim2_models/exceptions.py +265 -0
- scim2_models/messages/bulk.py +3 -7
- scim2_models/messages/error.py +184 -15
- scim2_models/messages/list_response.py +2 -5
- scim2_models/messages/message.py +4 -4
- scim2_models/messages/patch_op.py +127 -261
- scim2_models/messages/search_request.py +9 -57
- scim2_models/path.py +731 -0
- scim2_models/reference.py +139 -48
- scim2_models/resources/enterprise_user.py +10 -8
- scim2_models/resources/group.py +9 -7
- scim2_models/resources/resource.py +110 -23
- scim2_models/resources/resource_type.py +15 -14
- scim2_models/resources/schema.py +15 -20
- scim2_models/resources/service_provider_config.py +10 -13
- scim2_models/resources/user.py +13 -10
- scim2_models/scim_object.py +84 -1
- scim2_models/utils.py +0 -140
- {scim2_models-0.5.1.dist-info → scim2_models-0.6.0.dist-info}/METADATA +2 -2
- scim2_models-0.6.0.dist-info/RECORD +30 -0
- scim2_models/urn.py +0 -126
- scim2_models-0.5.1.dist-info/RECORD +0 -29
- {scim2_models-0.5.1.dist-info → scim2_models-0.6.0.dist-info}/WHEEL +0 -0
scim2_models/resources/schema.py
CHANGED
|
@@ -4,12 +4,11 @@ from enum import Enum
|
|
|
4
4
|
from typing import Annotated
|
|
5
5
|
from typing import Any
|
|
6
6
|
from typing import List # noqa : UP005,UP035
|
|
7
|
-
from typing import Literal
|
|
8
7
|
from typing import Optional
|
|
9
8
|
from typing import TypeVar
|
|
10
9
|
from typing import Union
|
|
11
|
-
from typing import get_origin
|
|
12
10
|
|
|
11
|
+
from pydantic import Base64Bytes
|
|
13
12
|
from pydantic import Field
|
|
14
13
|
from pydantic import create_model
|
|
15
14
|
from pydantic import field_validator
|
|
@@ -26,10 +25,10 @@ from ..attributes import ComplexAttribute
|
|
|
26
25
|
from ..attributes import is_complex_attribute
|
|
27
26
|
from ..base import BaseModel
|
|
28
27
|
from ..constants import RESERVED_WORDS
|
|
29
|
-
from ..
|
|
28
|
+
from ..path import URN
|
|
29
|
+
from ..reference import URI
|
|
30
|
+
from ..reference import External
|
|
30
31
|
from ..reference import Reference
|
|
31
|
-
from ..reference import URIReference
|
|
32
|
-
from ..utils import Base64Bytes
|
|
33
32
|
from ..utils import _normalize_attribute_name
|
|
34
33
|
from .resource import Resource
|
|
35
34
|
|
|
@@ -65,10 +64,6 @@ def _make_python_model(
|
|
|
65
64
|
for attr in (obj.attributes or [])
|
|
66
65
|
if attr.name
|
|
67
66
|
}
|
|
68
|
-
pydantic_attributes["schemas"] = (
|
|
69
|
-
Annotated[list[str], Required.true],
|
|
70
|
-
Field(default=[obj.id]),
|
|
71
|
-
)
|
|
72
67
|
|
|
73
68
|
if not obj.name:
|
|
74
69
|
raise ValueError("Schema or Attribute 'name' must be defined")
|
|
@@ -76,8 +71,9 @@ def _make_python_model(
|
|
|
76
71
|
model_name = to_pascal(to_snake(obj.name))
|
|
77
72
|
model: type[T] = create_model(model_name, __base__=base, **pydantic_attributes) # type: ignore[call-overload]
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
|
|
74
|
+
if isinstance(obj, Schema) and obj.id:
|
|
75
|
+
model.__schema__ = URN(obj.id) # type: ignore[attr-defined]
|
|
76
|
+
|
|
81
77
|
for attr_name in model.model_fields:
|
|
82
78
|
attr_type = model.get_field_root_type(attr_name)
|
|
83
79
|
if attr_type and is_complex_attribute(attr_type):
|
|
@@ -103,13 +99,14 @@ class Attribute(ComplexAttribute):
|
|
|
103
99
|
) -> type:
|
|
104
100
|
if self.value == self.reference and reference_types is not None:
|
|
105
101
|
if reference_types == ["external"]:
|
|
106
|
-
return Reference[
|
|
102
|
+
return Reference[External]
|
|
107
103
|
|
|
108
104
|
if reference_types == ["uri"]:
|
|
109
|
-
return Reference[
|
|
105
|
+
return Reference[URI]
|
|
110
106
|
|
|
111
|
-
|
|
112
|
-
|
|
107
|
+
if len(reference_types) == 1:
|
|
108
|
+
return Reference[reference_types[0]] # type: ignore[valid-type]
|
|
109
|
+
return Reference[Union[tuple(reference_types)]] # type: ignore[misc,return-value] # noqa: UP007
|
|
113
110
|
|
|
114
111
|
attr_types = {
|
|
115
112
|
self.string: str,
|
|
@@ -124,7 +121,7 @@ class Attribute(ComplexAttribute):
|
|
|
124
121
|
|
|
125
122
|
@classmethod
|
|
126
123
|
def from_python(cls, pytype: type) -> "Attribute.Type":
|
|
127
|
-
if
|
|
124
|
+
if isinstance(pytype, type) and issubclass(pytype, Reference):
|
|
128
125
|
return cls.reference
|
|
129
126
|
|
|
130
127
|
if pytype and is_complex_attribute(pytype):
|
|
@@ -254,9 +251,7 @@ class Attribute(ComplexAttribute):
|
|
|
254
251
|
|
|
255
252
|
|
|
256
253
|
class Schema(Resource[Any]):
|
|
257
|
-
schemas:
|
|
258
|
-
"urn:ietf:params:scim:schemas:core:2.0:Schema"
|
|
259
|
-
]
|
|
254
|
+
__schema__ = URN("urn:ietf:params:scim:schemas:core:2.0:Schema")
|
|
260
255
|
|
|
261
256
|
id: Annotated[str | None, Mutability.read_only, Required.true] = None
|
|
262
257
|
"""The unique URI of the schema."""
|
|
@@ -288,7 +283,7 @@ class Schema(Resource[Any]):
|
|
|
288
283
|
return attribute
|
|
289
284
|
return None
|
|
290
285
|
|
|
291
|
-
def __getitem__(self, name: str) -> "Attribute":
|
|
286
|
+
def __getitem__(self, name: str) -> "Attribute":
|
|
292
287
|
"""Find an attribute by its name."""
|
|
293
288
|
if attribute := self.get_attribute(name):
|
|
294
289
|
return attribute
|
|
@@ -9,7 +9,8 @@ from ..annotations import Required
|
|
|
9
9
|
from ..annotations import Returned
|
|
10
10
|
from ..annotations import Uniqueness
|
|
11
11
|
from ..attributes import ComplexAttribute
|
|
12
|
-
from ..
|
|
12
|
+
from ..path import URN
|
|
13
|
+
from ..reference import External
|
|
13
14
|
from ..reference import Reference
|
|
14
15
|
from .resource import Resource
|
|
15
16
|
|
|
@@ -73,15 +74,13 @@ class AuthenticationScheme(ComplexAttribute):
|
|
|
73
74
|
description: Annotated[str | None, Mutability.read_only, Required.true] = None
|
|
74
75
|
"""A description of the authentication scheme."""
|
|
75
76
|
|
|
76
|
-
spec_uri: Annotated[Reference[
|
|
77
|
-
None
|
|
78
|
-
)
|
|
77
|
+
spec_uri: Annotated[Reference[External] | None, Mutability.read_only] = None
|
|
79
78
|
"""An HTTP-addressable URL pointing to the authentication scheme's
|
|
80
79
|
specification."""
|
|
81
80
|
|
|
82
|
-
documentation_uri: Annotated[
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
documentation_uri: Annotated[Reference[External] | None, Mutability.read_only] = (
|
|
82
|
+
None
|
|
83
|
+
)
|
|
85
84
|
"""An HTTP-addressable URL pointing to the authentication scheme's usage
|
|
86
85
|
documentation."""
|
|
87
86
|
|
|
@@ -92,9 +91,7 @@ class AuthenticationScheme(ComplexAttribute):
|
|
|
92
91
|
|
|
93
92
|
|
|
94
93
|
class ServiceProviderConfig(Resource[Any]):
|
|
95
|
-
schemas:
|
|
96
|
-
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
|
|
97
|
-
]
|
|
94
|
+
__schema__ = URN("urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig")
|
|
98
95
|
|
|
99
96
|
id: Annotated[
|
|
100
97
|
str | None, Mutability.read_only, Returned.default, Uniqueness.global_
|
|
@@ -106,9 +103,9 @@ class ServiceProviderConfig(Resource[Any]):
|
|
|
106
103
|
# resources, the "id" attribute is not required for the service
|
|
107
104
|
# provider configuration resource
|
|
108
105
|
|
|
109
|
-
documentation_uri: Annotated[
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
documentation_uri: Annotated[Reference[External] | None, Mutability.read_only] = (
|
|
107
|
+
None
|
|
108
|
+
)
|
|
112
109
|
"""An HTTP-addressable URL pointing to the service provider's human-
|
|
113
110
|
consumable help documentation."""
|
|
114
111
|
|
scim2_models/resources/user.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
2
3
|
from typing import Annotated
|
|
3
4
|
from typing import ClassVar
|
|
4
|
-
from typing import
|
|
5
|
+
from typing import Union
|
|
5
6
|
|
|
7
|
+
from pydantic import Base64Bytes
|
|
6
8
|
from pydantic import EmailStr
|
|
7
9
|
from pydantic import Field
|
|
8
10
|
|
|
@@ -12,12 +14,15 @@ from ..annotations import Required
|
|
|
12
14
|
from ..annotations import Returned
|
|
13
15
|
from ..annotations import Uniqueness
|
|
14
16
|
from ..attributes import ComplexAttribute
|
|
15
|
-
from ..
|
|
17
|
+
from ..path import URN
|
|
18
|
+
from ..reference import External
|
|
16
19
|
from ..reference import Reference
|
|
17
|
-
from ..utils import Base64Bytes
|
|
18
20
|
from .resource import AnyExtension
|
|
19
21
|
from .resource import Resource
|
|
20
22
|
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .group import Group
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class Name(ComplexAttribute):
|
|
23
28
|
formatted: str | None = None
|
|
@@ -126,7 +131,7 @@ class Photo(ComplexAttribute):
|
|
|
126
131
|
photo = "photo"
|
|
127
132
|
thumbnail = "thumbnail"
|
|
128
133
|
|
|
129
|
-
value: Annotated[Reference[
|
|
134
|
+
value: Annotated[Reference[External] | None, CaseExact.true] = None
|
|
130
135
|
"""URL of a photo of the User."""
|
|
131
136
|
|
|
132
137
|
display: str | None = None
|
|
@@ -197,8 +202,8 @@ class GroupMembership(ComplexAttribute):
|
|
|
197
202
|
value: Annotated[str | None, Mutability.read_only] = None
|
|
198
203
|
"""The identifier of the User's group."""
|
|
199
204
|
|
|
200
|
-
ref: Annotated[
|
|
201
|
-
Reference[
|
|
205
|
+
ref: Annotated[ # type: ignore[type-arg]
|
|
206
|
+
Reference[Union["User", "Group"]] | None,
|
|
202
207
|
Mutability.read_only,
|
|
203
208
|
] = Field(None, serialization_alias="$ref")
|
|
204
209
|
"""The reference URI of a target resource, if the attribute is a
|
|
@@ -245,9 +250,7 @@ class X509Certificate(ComplexAttribute):
|
|
|
245
250
|
|
|
246
251
|
|
|
247
252
|
class User(Resource[AnyExtension]):
|
|
248
|
-
schemas:
|
|
249
|
-
"urn:ietf:params:scim:schemas:core:2.0:User"
|
|
250
|
-
]
|
|
253
|
+
__schema__ = URN("urn:ietf:params:scim:schemas:core:2.0:User")
|
|
251
254
|
|
|
252
255
|
user_name: Annotated[str | None, Uniqueness.server, Required.true] = None
|
|
253
256
|
"""Unique identifier for the User, typically used by the user to directly
|
|
@@ -265,7 +268,7 @@ class User(Resource[AnyExtension]):
|
|
|
265
268
|
"""The casual way to address the user in real life, e.g., 'Bob' or 'Bobby'
|
|
266
269
|
instead of 'Robert'."""
|
|
267
270
|
|
|
268
|
-
profile_url: Reference[
|
|
271
|
+
profile_url: Reference[External] | None = None
|
|
269
272
|
"""A fully qualified URL pointing to a page representing the User's online
|
|
270
273
|
profile."""
|
|
271
274
|
|
scim2_models/scim_object.py
CHANGED
|
@@ -1,24 +1,107 @@
|
|
|
1
1
|
"""Base SCIM object classes with schema identification."""
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from typing import TYPE_CHECKING
|
|
4
5
|
from typing import Annotated
|
|
5
6
|
from typing import Any
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationInfo
|
|
10
|
+
from pydantic import ValidatorFunctionWrapHandler
|
|
11
|
+
from pydantic import model_validator
|
|
12
|
+
from pydantic._internal._model_construction import ModelMetaclass
|
|
13
|
+
from pydantic_core import PydanticCustomError
|
|
14
|
+
from typing_extensions import Self
|
|
6
15
|
|
|
7
16
|
from .annotations import Required
|
|
8
17
|
from .base import BaseModel
|
|
9
18
|
from .context import Context
|
|
19
|
+
from .path import URN
|
|
10
20
|
|
|
11
21
|
if TYPE_CHECKING:
|
|
12
22
|
pass
|
|
13
23
|
|
|
14
24
|
|
|
15
|
-
class
|
|
25
|
+
class ScimMetaclass(ModelMetaclass):
|
|
26
|
+
"""Metaclass for SCIM objects that handles __schema__ backward compatibility."""
|
|
27
|
+
|
|
28
|
+
def __new__(
|
|
29
|
+
mcs,
|
|
30
|
+
name: str,
|
|
31
|
+
bases: tuple[type, ...],
|
|
32
|
+
namespace: dict[str, Any],
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> type:
|
|
35
|
+
cls = super().__new__(mcs, name, bases, namespace, **kwargs)
|
|
36
|
+
|
|
37
|
+
if name in ("ScimObject", "Resource", "Extension"):
|
|
38
|
+
return cls
|
|
39
|
+
|
|
40
|
+
if getattr(cls, "__schema__", None) is None:
|
|
41
|
+
schemas_field = cls.model_fields.get("schemas") # type: ignore[attr-defined]
|
|
42
|
+
if (
|
|
43
|
+
schemas_field
|
|
44
|
+
and schemas_field.default
|
|
45
|
+
and isinstance(schemas_field.default, list)
|
|
46
|
+
and schemas_field.default
|
|
47
|
+
):
|
|
48
|
+
schema_value = schemas_field.default[0]
|
|
49
|
+
try:
|
|
50
|
+
cls.__schema__ = URN(schema_value) # type: ignore[attr-defined]
|
|
51
|
+
warnings.warn(
|
|
52
|
+
f"{name}: Defining schemas with a default value is deprecated "
|
|
53
|
+
f"and will be removed in version 0.7. "
|
|
54
|
+
f'Use __schema__ = URN("{schema_value}") instead.',
|
|
55
|
+
DeprecationWarning,
|
|
56
|
+
stacklevel=2,
|
|
57
|
+
)
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
return cls
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ScimObject(BaseModel, metaclass=ScimMetaclass):
|
|
65
|
+
__schema__: ClassVar[URN | None] = None
|
|
66
|
+
|
|
16
67
|
schemas: Annotated[list[str], Required.true]
|
|
17
68
|
"""The "schemas" attribute is a REQUIRED attribute and is an array of
|
|
18
69
|
Strings containing URIs that are used to indicate the namespaces of the
|
|
19
70
|
SCIM schemas that define the attributes present in the current JSON
|
|
20
71
|
structure."""
|
|
21
72
|
|
|
73
|
+
@model_validator(mode="before")
|
|
74
|
+
@classmethod
|
|
75
|
+
def _populate_schemas_default(cls, data: Any) -> Any:
|
|
76
|
+
"""Auto-generate schemas from __schema__ if not provided."""
|
|
77
|
+
if isinstance(data, dict) and "schemas" not in data:
|
|
78
|
+
schema = getattr(cls, "__schema__", None)
|
|
79
|
+
if schema:
|
|
80
|
+
data = {**data, "schemas": [schema]}
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="wrap")
|
|
84
|
+
@classmethod
|
|
85
|
+
def _validate_schemas_attribute(
|
|
86
|
+
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
|
|
87
|
+
) -> Self:
|
|
88
|
+
"""Validate that the base schema is present in schemas attribute."""
|
|
89
|
+
obj: Self = handler(value)
|
|
90
|
+
|
|
91
|
+
scim_ctx = info.context.get("scim") if info.context else None
|
|
92
|
+
if scim_ctx is None or scim_ctx == Context.DEFAULT:
|
|
93
|
+
return obj
|
|
94
|
+
|
|
95
|
+
schema = getattr(cls, "__schema__", None)
|
|
96
|
+
if schema and schema not in obj.schemas:
|
|
97
|
+
raise PydanticCustomError(
|
|
98
|
+
"schema_error",
|
|
99
|
+
"schemas must contain the base schema '{schema}'",
|
|
100
|
+
{"schema": schema},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return obj
|
|
104
|
+
|
|
22
105
|
def _prepare_model_dump(
|
|
23
106
|
self,
|
|
24
107
|
scim_ctx: Context | None = Context.DEFAULT,
|
scim2_models/utils.py
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
import base64
|
|
2
1
|
import re
|
|
3
2
|
from typing import TYPE_CHECKING
|
|
4
|
-
from typing import Annotated
|
|
5
|
-
from typing import Literal
|
|
6
3
|
from typing import Union
|
|
7
4
|
|
|
8
|
-
from pydantic import EncodedBytes
|
|
9
|
-
from pydantic import EncoderProtocol
|
|
10
5
|
from pydantic.alias_generators import to_snake
|
|
11
|
-
from pydantic_core import PydanticCustomError
|
|
12
6
|
|
|
13
7
|
if TYPE_CHECKING:
|
|
14
8
|
from .base import BaseModel
|
|
@@ -23,64 +17,12 @@ except ImportError:
|
|
|
23
17
|
|
|
24
18
|
_UNDERSCORE_ALPHANUMERIC = re.compile(r"_+([0-9A-Za-z]+)")
|
|
25
19
|
_NON_WORD_UNDERSCORE = re.compile(r"[\W_]+")
|
|
26
|
-
_VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$')
|
|
27
20
|
|
|
28
21
|
|
|
29
22
|
def _int_to_str(status: int | None) -> str | None:
|
|
30
23
|
return None if status is None else str(status)
|
|
31
24
|
|
|
32
25
|
|
|
33
|
-
# Copied from Pydantic 2.10 repository
|
|
34
|
-
class _Base64Encoder(EncoderProtocol): # pragma: no cover
|
|
35
|
-
"""Standard (non-URL-safe) Base64 encoder."""
|
|
36
|
-
|
|
37
|
-
@classmethod
|
|
38
|
-
def decode(cls, data: bytes) -> bytes:
|
|
39
|
-
"""Decode the data from base64 encoded bytes to original bytes data.
|
|
40
|
-
|
|
41
|
-
Args:
|
|
42
|
-
data: The data to decode.
|
|
43
|
-
|
|
44
|
-
Returns:
|
|
45
|
-
The decoded data.
|
|
46
|
-
|
|
47
|
-
"""
|
|
48
|
-
try:
|
|
49
|
-
return base64.b64decode(data)
|
|
50
|
-
except ValueError as e:
|
|
51
|
-
raise PydanticCustomError(
|
|
52
|
-
"base64_decode", "Base64 decoding error: '{error}'", {"error": str(e)}
|
|
53
|
-
) from e
|
|
54
|
-
|
|
55
|
-
@classmethod
|
|
56
|
-
def encode(cls, value: bytes) -> bytes:
|
|
57
|
-
"""Encode the data from bytes to a base64 encoded bytes.
|
|
58
|
-
|
|
59
|
-
Args:
|
|
60
|
-
value: The data to encode.
|
|
61
|
-
|
|
62
|
-
Returns:
|
|
63
|
-
The encoded data.
|
|
64
|
-
|
|
65
|
-
"""
|
|
66
|
-
return base64.b64encode(value)
|
|
67
|
-
|
|
68
|
-
@classmethod
|
|
69
|
-
def get_json_format(cls) -> Literal["base64"]:
|
|
70
|
-
"""Get the JSON format for the encoded data.
|
|
71
|
-
|
|
72
|
-
Returns:
|
|
73
|
-
The JSON format for the encoded data.
|
|
74
|
-
|
|
75
|
-
"""
|
|
76
|
-
return "base64"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Compatibility with Pydantic <2.10
|
|
80
|
-
# https://pydantic.dev/articles/pydantic-v2-10-release#use-b64decode-and-b64encode-for-base64bytes-and-base64str-types
|
|
81
|
-
Base64Bytes = Annotated[bytes, EncodedBytes(encoder=_Base64Encoder)]
|
|
82
|
-
|
|
83
|
-
|
|
84
26
|
def _to_camel(string: str) -> str:
|
|
85
27
|
"""Transform strings to camelCase.
|
|
86
28
|
|
|
@@ -106,84 +48,6 @@ def _normalize_attribute_name(attribute_name: str) -> str:
|
|
|
106
48
|
return attribute_name.lower()
|
|
107
49
|
|
|
108
50
|
|
|
109
|
-
def _validate_scim_path_syntax(path: str) -> bool:
|
|
110
|
-
"""Check if path syntax is valid according to RFC 7644 simplified rules.
|
|
111
|
-
|
|
112
|
-
:param path: The path to validate
|
|
113
|
-
:return: True if path syntax is valid, False otherwise
|
|
114
|
-
"""
|
|
115
|
-
if not path or not path.strip():
|
|
116
|
-
return False
|
|
117
|
-
|
|
118
|
-
# Cannot start with a digit
|
|
119
|
-
if path[0].isdigit():
|
|
120
|
-
return False
|
|
121
|
-
|
|
122
|
-
# Cannot contain double dots
|
|
123
|
-
if ".." in path:
|
|
124
|
-
return False
|
|
125
|
-
|
|
126
|
-
# Cannot contain invalid characters (basic check)
|
|
127
|
-
# Allow alphanumeric, dots, underscores, hyphens, colons (for URNs), brackets
|
|
128
|
-
if not _VALID_PATH_PATTERN.match(path):
|
|
129
|
-
return False
|
|
130
|
-
|
|
131
|
-
# If it contains a colon, validate it's a proper URN format
|
|
132
|
-
if ":" in path:
|
|
133
|
-
if not _validate_scim_urn_syntax(path):
|
|
134
|
-
return False
|
|
135
|
-
|
|
136
|
-
return True
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def _validate_scim_urn_syntax(path: str) -> bool:
|
|
140
|
-
"""Validate URN-based path format.
|
|
141
|
-
|
|
142
|
-
:param path: The URN path to validate
|
|
143
|
-
:return: True if URN path format is valid, False otherwise
|
|
144
|
-
"""
|
|
145
|
-
# Basic URN validation: should start with urn:
|
|
146
|
-
if not path.startswith("urn:"):
|
|
147
|
-
return False
|
|
148
|
-
|
|
149
|
-
# Split on the last colon to separate URN from attribute
|
|
150
|
-
urn_part, attr_part = path.rsplit(":", 1)
|
|
151
|
-
|
|
152
|
-
# URN part should have at least 4 parts (urn:namespace:specific:resource)
|
|
153
|
-
urn_segments = urn_part.split(":")
|
|
154
|
-
if len(urn_segments) < 4:
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
# Attribute part should be valid
|
|
158
|
-
if not attr_part or attr_part[0].isdigit():
|
|
159
|
-
return False
|
|
160
|
-
|
|
161
|
-
return True
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def _extract_field_name(path: str) -> str | None:
|
|
165
|
-
"""Extract the field name from a path.
|
|
166
|
-
|
|
167
|
-
For now, only handle simple paths (no filters, no complex expressions).
|
|
168
|
-
Returns None for complex paths that require filter parsing.
|
|
169
|
-
|
|
170
|
-
"""
|
|
171
|
-
# Handle URN paths
|
|
172
|
-
if path.startswith("urn:"):
|
|
173
|
-
# First validate it's a proper URN
|
|
174
|
-
if not _validate_scim_urn_syntax(path):
|
|
175
|
-
return None
|
|
176
|
-
parts = path.rsplit(":", 1)
|
|
177
|
-
return parts[1]
|
|
178
|
-
|
|
179
|
-
# Simple attribute path (may have dots for sub-attributes)
|
|
180
|
-
# For now, just take the first part before any dot
|
|
181
|
-
if "." in path:
|
|
182
|
-
return path.split(".")[0]
|
|
183
|
-
|
|
184
|
-
return path
|
|
185
|
-
|
|
186
|
-
|
|
187
51
|
def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> str | None:
|
|
188
52
|
"""Find the actual field name in a resource class from an attribute name.
|
|
189
53
|
|
|
@@ -198,7 +62,3 @@ def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> str | No
|
|
|
198
62
|
return field_key
|
|
199
63
|
|
|
200
64
|
return None
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def _get_path_parts(path: str) -> list[str]:
|
|
204
|
-
return path.split(".")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: scim2-models
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
scim2_models/__init__.py,sha256=mEaFa7STotG40wV9cy9aw00Klcx7mqpwJXFhir1BKmI,4213
|
|
2
|
+
scim2_models/annotations.py,sha256=oRjlKL1fqrYfa9UtaMdxF5fOT8CUUN3m-rdzvf7aiSA,3304
|
|
3
|
+
scim2_models/attributes.py,sha256=v_bjJSdBmPlGh20W2Y57PIZnqvMTYHQyJuJLfQjGmwc,1874
|
|
4
|
+
scim2_models/base.py,sha256=dB0idU8apEwPwJpax1EWYX-omxcIy9Z4DMjNhrwTJAg,22407
|
|
5
|
+
scim2_models/constants.py,sha256=9egq8JW0dFAqPng85CiHoH5T6pRtYL87-gC0C-IMGsk,573
|
|
6
|
+
scim2_models/context.py,sha256=RjgMIvWPr8f41qbVL1sjaDnm9GRKyrCrgfC4npwwcMg,9149
|
|
7
|
+
scim2_models/exceptions.py,sha256=0rvJTRM3CbaB2-T_PAhii5W-YEvTY31scPdrauGJi0M,7655
|
|
8
|
+
scim2_models/messages/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
scim2_models/messages/bulk.py,sha256=fWm9s88FHa66Aa0XeLcN7HurHp98JhlqM1rkEnIKZ8A,2429
|
|
10
|
+
scim2_models/messages/error.py,sha256=gbHxf47ZIamnZAgD7cNAwHXxEZsf36PTdtcejesYIy0,12138
|
|
11
|
+
scim2_models/messages/list_response.py,sha256=ahAqdElGSNxt8LlOUeSsNbOykZdCLM4aJyirKcaOtoM,2260
|
|
12
|
+
scim2_models/messages/message.py,sha256=7svNN_xi8BSyWmRoMdYFTjQGuV1F9HUpQYHb6TQtf8A,4081
|
|
13
|
+
scim2_models/messages/patch_op.py,sha256=QwAkeHvzzUXFmS8Ut_ns99yhiSQehpuHAodI_eSc_hA,16236
|
|
14
|
+
scim2_models/messages/search_request.py,sha256=OXw4-oehLC5NyD7yVFEq0QgPsxAaQwXE7hZMKiNkvzY,3017
|
|
15
|
+
scim2_models/path.py,sha256=uEt6d-ebHH9aJTizzXDDOJknVA7CbEe8YVQa6x8JEqE,26975
|
|
16
|
+
scim2_models/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
scim2_models/reference.py,sha256=-E6VD-lFPEIXpSpwu0G_3MKLbWKgZhwk8ER3-JhCHig,5636
|
|
18
|
+
scim2_models/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
scim2_models/resources/enterprise_user.py,sha256=Wk8CzQrCXUE-roQYINpj4YhOlj2s_TD3Y3SnEXK6jKY,1798
|
|
20
|
+
scim2_models/resources/group.py,sha256=nmmPlbP7gQluNJEtFIFYtjDYJ2AR4Uml5LuxMcY_NIk,1379
|
|
21
|
+
scim2_models/resources/resource.py,sha256=nzwb_ZjW2xx22gSyXhyrEcrKFvhD7y9RrzaN610Rbyo,20478
|
|
22
|
+
scim2_models/resources/resource_type.py,sha256=IDzUcv9_XiAHOpaaG9j24sa-nNKY6i0wPDTTrxNqJgQ,3297
|
|
23
|
+
scim2_models/resources/schema.py,sha256=wDh8G1oxCgyQcmChz30bVNPjyksYcgxD66k0mB1VIdI,10239
|
|
24
|
+
scim2_models/resources/service_provider_config.py,sha256=cmfPtLPOuwNrZl9hGpbrMsQhoOwIytlf2Q_LAJegAcQ,5303
|
|
25
|
+
scim2_models/resources/user.py,sha256=ZvyjxZiF52d_L_LUCEZpec6CXOe40_udZhOHpjzFpxs,11409
|
|
26
|
+
scim2_models/scim_object.py,sha256=suYhQ0iFQs1aKTlSerWZsi_sAVhFX_9N24U_DmZxCbw,5321
|
|
27
|
+
scim2_models/utils.py,sha256=Lb7mlP3I_IfAlqi_8_m4G-_1Rn7oKRqbNdpoDLjXXr0,1978
|
|
28
|
+
scim2_models-0.6.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
29
|
+
scim2_models-0.6.0.dist-info/METADATA,sha256=ZCHD84CvfCgyOaTYiKvbfx5EisWpuqd0JhahCxW9_go,16486
|
|
30
|
+
scim2_models-0.6.0.dist-info/RECORD,,
|
scim2_models/urn.py
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
from typing import TYPE_CHECKING
|
|
2
|
-
from typing import Any
|
|
3
|
-
from typing import Union
|
|
4
|
-
|
|
5
|
-
from .base import BaseModel
|
|
6
|
-
from .utils import _get_path_parts
|
|
7
|
-
from .utils import _normalize_attribute_name
|
|
8
|
-
|
|
9
|
-
if TYPE_CHECKING:
|
|
10
|
-
from .base import BaseModel
|
|
11
|
-
from .resources.resource import Extension
|
|
12
|
-
from .resources.resource import Resource
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def _get_or_create_extension_instance(
|
|
16
|
-
model: "Resource[Any]", extension_class: type
|
|
17
|
-
) -> "Extension":
|
|
18
|
-
"""Get existing extension instance or create a new one."""
|
|
19
|
-
extension_instance = model[extension_class]
|
|
20
|
-
if extension_instance is None:
|
|
21
|
-
extension_instance = extension_class()
|
|
22
|
-
model[extension_class] = extension_instance
|
|
23
|
-
return extension_instance
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _normalize_path(model: type["BaseModel"] | None, path: str) -> tuple[str, str]:
|
|
27
|
-
"""Resolve a path to (schema_urn, attribute_path)."""
|
|
28
|
-
from .resources.resource import Resource
|
|
29
|
-
|
|
30
|
-
# Absolute URN
|
|
31
|
-
if ":" in path:
|
|
32
|
-
if (
|
|
33
|
-
model
|
|
34
|
-
and issubclass(model, Resource)
|
|
35
|
-
and (
|
|
36
|
-
path in model.get_extension_models()
|
|
37
|
-
or path == model.model_fields["schemas"].default[0]
|
|
38
|
-
)
|
|
39
|
-
):
|
|
40
|
-
return path, ""
|
|
41
|
-
|
|
42
|
-
parts = path.rsplit(":", 1)
|
|
43
|
-
return parts[0], parts[1]
|
|
44
|
-
|
|
45
|
-
# Relative URN with a schema
|
|
46
|
-
elif model and issubclass(model, Resource) and hasattr(model, "model_fields"):
|
|
47
|
-
schemas_field = model.model_fields.get("schemas")
|
|
48
|
-
return schemas_field.default[0], path # type: ignore
|
|
49
|
-
|
|
50
|
-
return "", path
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
|
|
54
|
-
"""Validate that an attribute name or a sub-attribute path exist for a given model."""
|
|
55
|
-
attribute_name, *sub_attribute_blocks = _get_path_parts(attribute_base)
|
|
56
|
-
sub_attribute_base = ".".join(sub_attribute_blocks)
|
|
57
|
-
|
|
58
|
-
aliases = {field.validation_alias for field in model.model_fields.values()}
|
|
59
|
-
|
|
60
|
-
if _normalize_attribute_name(attribute_name) not in aliases:
|
|
61
|
-
raise ValueError(
|
|
62
|
-
f"Model '{model.__name__}' has no attribute named '{attribute_name}'"
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
if sub_attribute_base:
|
|
66
|
-
attribute_type = model.get_field_root_type(attribute_name)
|
|
67
|
-
|
|
68
|
-
if not attribute_type or not issubclass(attribute_type, BaseModel):
|
|
69
|
-
raise ValueError(
|
|
70
|
-
f"Attribute '{attribute_name}' is not a complex attribute, and cannot have a '{sub_attribute_base}' sub-attribute"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
_validate_model_attribute(attribute_type, sub_attribute_base)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _validate_attribute_urn(
|
|
77
|
-
attribute_name: str, resource: type["Resource[Any]"]
|
|
78
|
-
) -> str | None:
|
|
79
|
-
"""Validate that an attribute urn is valid or not.
|
|
80
|
-
|
|
81
|
-
:param attribute_name: The attribute urn to check.
|
|
82
|
-
:return: The normalized attribute URN.
|
|
83
|
-
"""
|
|
84
|
-
from .resources.resource import Resource
|
|
85
|
-
|
|
86
|
-
schema: Any | None
|
|
87
|
-
schema, attribute_base = _normalize_path(resource, attribute_name)
|
|
88
|
-
|
|
89
|
-
validated_resource = Resource.get_by_schema([resource], schema)
|
|
90
|
-
if not validated_resource:
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
try:
|
|
94
|
-
_validate_model_attribute(validated_resource, attribute_base)
|
|
95
|
-
except ValueError:
|
|
96
|
-
return None
|
|
97
|
-
|
|
98
|
-
return f"{schema}:{attribute_base}"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _resolve_path_to_target(
|
|
102
|
-
resource: "Resource[Any]", path: str
|
|
103
|
-
) -> tuple[Union["Resource[Any]", "Extension"] | None, str]:
|
|
104
|
-
"""Resolve a path to a target and an attribute_path.
|
|
105
|
-
|
|
106
|
-
The target can be the resource itself, or an extension object.
|
|
107
|
-
"""
|
|
108
|
-
schema_urn, attr_path = _normalize_path(type(resource), path)
|
|
109
|
-
|
|
110
|
-
if not schema_urn:
|
|
111
|
-
return resource, attr_path
|
|
112
|
-
|
|
113
|
-
if extension_class := resource.get_extension_model(schema_urn):
|
|
114
|
-
# Points to the extension root
|
|
115
|
-
if not attr_path:
|
|
116
|
-
return resource, extension_class.__name__
|
|
117
|
-
|
|
118
|
-
extension_instance = _get_or_create_extension_instance(
|
|
119
|
-
resource, extension_class
|
|
120
|
-
)
|
|
121
|
-
return extension_instance, attr_path
|
|
122
|
-
|
|
123
|
-
if schema_urn in resource.schemas:
|
|
124
|
-
return resource, attr_path
|
|
125
|
-
|
|
126
|
-
return (None, "")
|