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.
Files changed (34) hide show
  1. scim2_models/__init__.py +52 -52
  2. scim2_models/annotations.py +104 -0
  3. scim2_models/attributes.py +57 -0
  4. scim2_models/base.py +195 -554
  5. scim2_models/constants.py +3 -3
  6. scim2_models/context.py +168 -0
  7. scim2_models/{rfc7644 → messages}/bulk.py +4 -4
  8. scim2_models/{rfc7644 → messages}/error.py +13 -13
  9. scim2_models/messages/list_response.py +75 -0
  10. scim2_models/messages/message.py +119 -0
  11. scim2_models/messages/patch_op.py +478 -0
  12. scim2_models/{rfc7644 → messages}/search_request.py +55 -6
  13. scim2_models/reference.py +82 -0
  14. scim2_models/{rfc7643 → resources}/enterprise_user.py +4 -4
  15. scim2_models/{rfc7643 → resources}/group.py +5 -5
  16. scim2_models/resources/resource.py +468 -0
  17. scim2_models/{rfc7643 → resources}/resource_type.py +13 -22
  18. scim2_models/{rfc7643 → resources}/schema.py +51 -45
  19. scim2_models/{rfc7643 → resources}/service_provider_config.py +7 -7
  20. scim2_models/{rfc7643 → resources}/user.py +9 -9
  21. scim2_models/scim_object.py +66 -0
  22. scim2_models/urn.py +109 -0
  23. scim2_models/utils.py +108 -6
  24. {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/METADATA +1 -1
  25. scim2_models-0.4.0.dist-info/RECORD +30 -0
  26. scim2_models/rfc7643/resource.py +0 -355
  27. scim2_models/rfc7644/list_response.py +0 -136
  28. scim2_models/rfc7644/message.py +0 -10
  29. scim2_models/rfc7644/patch_op.py +0 -70
  30. scim2_models-0.3.6.dist-info/RECORD +0 -24
  31. /scim2_models/{rfc7643 → messages}/__init__.py +0 -0
  32. /scim2_models/{rfc7644 → resources}/__init__.py +0 -0
  33. {scim2_models-0.3.6.dist-info → scim2_models-0.4.0.dist-info}/WHEEL +0 -0
  34. {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 normalize_attribute_name
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
- def make_python_identifier(identifier: str) -> str:
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 make_python_model(
49
+ def _make_python_model(
48
50
  obj: Union["Schema", "Attribute"],
49
- base: Optional[type[BaseModel]] = None,
50
- multiple=False,
51
- ) -> Union[Resource, Extension]:
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(make_python_identifier(attr.name)): attr.to_python()
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(make_python_identifier(attr.name)): attr.to_python()
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 to_python(
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) -> str:
129
+ def from_python(cls, pytype: type) -> "Attribute.Type":
126
130
  if get_origin(pytype) == Reference:
127
- return cls.reference.value
131
+ return cls.reference
128
132
 
129
- if is_complex_attribute(pytype):
130
- return cls.complex.value
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.value
137
+ return cls.boolean
134
138
 
135
139
  attr_types = {
136
- str: cls.string.value,
137
- bool: cls.boolean.value,
138
- float: cls.decimal.value,
139
- int: cls.integer.value,
140
- datetime: cls.date_time.value,
141
- Base64Bytes: cls.binary.value,
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.value)
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, Field]]:
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(obj=self, multiple=self.multi_valued)
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=normalize_attribute_name(self.name),
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 ..base import ComplexAttribute
8
- from ..base import ExternalReference
9
- from ..base import Mutability
10
- from ..base import Reference
11
- from ..base import Required
12
- from ..base import Returned
13
- from ..base import Uniqueness
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 ..base import CaseExact
12
- from ..base import ComplexAttribute
13
- from ..base import ExternalReference
14
- from ..base import MultiValuedComplexAttribute
15
- from ..base import Mutability
16
- from ..base import Reference
17
- from ..base import Required
18
- from ..base import Returned
19
- from ..base import Uniqueness
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 # type: ignore
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 int_to_str(status: Optional[int]) -> Optional[str]:
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 Base64Encoder(EncoderProtocol): # pragma: no cover
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=Base64Encoder)]
74
+ Base64Bytes = Annotated[bytes, EncodedBytes(encoder=_Base64Encoder)]
75
75
 
76
76
 
77
- def to_camel(string: str) -> str:
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 normalize_attribute_name(attribute_name: str) -> str:
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.6
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,,