scim2-models 0.4.2__py3-none-any.whl → 0.5.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.
@@ -2,8 +2,6 @@ from enum import Enum
2
2
  from typing import Annotated
3
3
  from typing import ClassVar
4
4
  from typing import Literal
5
- from typing import Optional
6
- from typing import Union
7
5
 
8
6
  from pydantic import EmailStr
9
7
  from pydantic import Field
@@ -14,7 +12,6 @@ from ..annotations import Required
14
12
  from ..annotations import Returned
15
13
  from ..annotations import Uniqueness
16
14
  from ..attributes import ComplexAttribute
17
- from ..attributes import MultiValuedComplexAttribute
18
15
  from ..reference import ExternalReference
19
16
  from ..reference import Reference
20
17
  from ..utils import Base64Bytes
@@ -23,53 +20,53 @@ from .resource import Resource
23
20
 
24
21
 
25
22
  class Name(ComplexAttribute):
26
- formatted: Optional[str] = None
23
+ formatted: str | None = None
27
24
  """The full name, including all middle names, titles, and suffixes as
28
25
  appropriate, formatted for display (e.g., 'Ms. Barbara J Jensen, III')."""
29
26
 
30
- family_name: Optional[str] = None
27
+ family_name: str | None = None
31
28
  """The family name of the User, or last name in most Western languages
32
29
  (e.g., 'Jensen' given the full name 'Ms. Barbara J Jensen, III')."""
33
30
 
34
- given_name: Optional[str] = None
31
+ given_name: str | None = None
35
32
  """The given name of the User, or first name in most Western languages
36
33
  (e.g., 'Barbara' given the full name 'Ms. Barbara J Jensen, III')."""
37
34
 
38
- middle_name: Optional[str] = None
35
+ middle_name: str | None = None
39
36
  """The middle name(s) of the User (e.g., 'Jane' given the full name 'Ms.
40
37
  Barbara J Jensen, III')."""
41
38
 
42
- honorific_prefix: Optional[str] = None
39
+ honorific_prefix: str | None = None
43
40
  """The honorific prefix(es) of the User, or title in most Western languages
44
41
  (e.g., 'Ms.' given the full name 'Ms. Barbara J Jensen, III')."""
45
42
 
46
- honorific_suffix: Optional[str] = None
43
+ honorific_suffix: str | None = None
47
44
  """The honorific suffix(es) of the User, or suffix in most Western
48
45
  languages (e.g., 'III' given the full name 'Ms. Barbara J Jensen, III')."""
49
46
 
50
47
 
51
- class Email(MultiValuedComplexAttribute):
48
+ class Email(ComplexAttribute):
52
49
  class Type(str, Enum):
53
50
  work = "work"
54
51
  home = "home"
55
52
  other = "other"
56
53
 
57
- value: Optional[EmailStr] = None
54
+ value: EmailStr | None = None
58
55
  """Email addresses for the user."""
59
56
 
60
- display: Optional[str] = None
57
+ display: str | None = None
61
58
  """A human-readable name, primarily used for display purposes."""
62
59
 
63
- type: Optional[Type] = Field(None, examples=["work", "home", "other"])
60
+ type: Type | None = Field(None, examples=["work", "home", "other"])
64
61
  """A label indicating the attribute's function, e.g., 'work' or 'home'."""
65
62
 
66
- primary: Optional[bool] = None
63
+ primary: bool | None = None
67
64
  """A Boolean value indicating the 'primary' or preferred attribute value
68
65
  for this attribute, e.g., the preferred mailing address or primary email
69
66
  address."""
70
67
 
71
68
 
72
- class PhoneNumber(MultiValuedComplexAttribute):
69
+ class PhoneNumber(ComplexAttribute):
73
70
  class Type(str, Enum):
74
71
  work = "work"
75
72
  home = "home"
@@ -78,25 +75,25 @@ class PhoneNumber(MultiValuedComplexAttribute):
78
75
  pager = "pager"
79
76
  other = "other"
80
77
 
81
- value: Optional[str] = None
78
+ value: str | None = None
82
79
  """Phone number of the User."""
83
80
 
84
- display: Optional[str] = None
81
+ display: str | None = None
85
82
  """A human-readable name, primarily used for display purposes."""
86
83
 
87
- type: Optional[Type] = Field(
84
+ type: Type | None = Field(
88
85
  None, examples=["work", "home", "mobile", "fax", "pager", "other"]
89
86
  )
90
87
  """A label indicating the attribute's function, e.g., 'work', 'home',
91
88
  'mobile'."""
92
89
 
93
- primary: Optional[bool] = None
90
+ primary: bool | None = None
94
91
  """A Boolean value indicating the 'primary' or preferred attribute value
95
92
  for this attribute, e.g., the preferred phone number or primary phone
96
93
  number."""
97
94
 
98
95
 
99
- class Im(MultiValuedComplexAttribute):
96
+ class Im(ComplexAttribute):
100
97
  class Type(str, Enum):
101
98
  aim = "aim"
102
99
  gtalk = "gtalk"
@@ -107,214 +104,246 @@ class Im(MultiValuedComplexAttribute):
107
104
  qq = "qq"
108
105
  yahoo = "yahoo"
109
106
 
110
- value: Optional[str] = None
107
+ value: str | None = None
111
108
  """Instant messaging address for the User."""
112
109
 
113
- display: Optional[str] = None
110
+ display: str | None = None
114
111
  """A human-readable name, primarily used for display purposes."""
115
112
 
116
- type: Optional[Type] = Field(
113
+ type: Type | None = Field(
117
114
  None, examples=["aim", "gtalk", "icq", "xmpp", "msn", "skype", "qq", "yahoo"]
118
115
  )
119
116
  """A label indicating the attribute's function, e.g., 'aim', 'gtalk',
120
117
  'xmpp'."""
121
118
 
122
- primary: Optional[bool] = None
119
+ primary: bool | None = None
123
120
  """A Boolean value indicating the 'primary' or preferred attribute value
124
121
  for this attribute, e.g., the preferred messenger or primary messenger."""
125
122
 
126
123
 
127
- class Photo(MultiValuedComplexAttribute):
124
+ class Photo(ComplexAttribute):
128
125
  class Type(str, Enum):
129
126
  photo = "photo"
130
127
  thumbnail = "thumbnail"
131
128
 
132
- value: Annotated[Optional[Reference[ExternalReference]], CaseExact.true] = None
129
+ value: Annotated[Reference[ExternalReference] | None, CaseExact.true] = None
133
130
  """URL of a photo of the User."""
134
131
 
135
- display: Optional[str] = None
132
+ display: str | None = None
136
133
  """A human-readable name, primarily used for display purposes."""
137
134
 
138
- type: Optional[Type] = Field(None, examples=["photo", "thumbnail"])
135
+ type: Type | None = Field(None, examples=["photo", "thumbnail"])
139
136
  """A label indicating the attribute's function, i.e., 'photo' or
140
137
  'thumbnail'."""
141
138
 
142
- primary: Optional[bool] = None
139
+ primary: bool | None = None
143
140
  """A Boolean value indicating the 'primary' or preferred attribute value
144
141
  for this attribute, e.g., the preferred photo or thumbnail."""
145
142
 
146
143
 
147
- class Address(MultiValuedComplexAttribute):
144
+ class Address(ComplexAttribute):
148
145
  class Type(str, Enum):
149
146
  work = "work"
150
147
  home = "home"
151
148
  other = "other"
152
149
 
153
- formatted: Optional[str] = None
150
+ formatted: str | None = None
154
151
  """The full mailing address, formatted for display or use with a mailing
155
152
  label."""
156
153
 
157
- street_address: Optional[str] = None
154
+ street_address: str | None = None
158
155
  """The full street address component, which may include house number,
159
156
  street name, P.O.
160
157
 
161
158
  box, and multi-line extended street address information.
162
159
  """
163
160
 
164
- locality: Optional[str] = None
161
+ locality: str | None = None
165
162
  """The city or locality component."""
166
163
 
167
- region: Optional[str] = None
164
+ region: str | None = None
168
165
  """The state or region component."""
169
166
 
170
- postal_code: Optional[str] = None
167
+ postal_code: str | None = None
171
168
  """The zip code or postal code component."""
172
169
 
173
- country: Optional[str] = None
170
+ country: str | None = None
174
171
  """The country name component."""
175
172
 
176
- type: Optional[Type] = Field(None, examples=["work", "home", "other"])
173
+ type: Type | None = Field(None, examples=["work", "home", "other"])
177
174
  """A label indicating the attribute's function, e.g., 'work' or 'home'."""
178
175
 
179
- primary: Optional[bool] = None
176
+ primary: bool | None = None
180
177
  """A Boolean value indicating the 'primary' or preferred attribute value
181
178
  for this attribute, e.g., the preferred photo or thumbnail."""
182
179
 
183
180
 
184
- class Entitlement(MultiValuedComplexAttribute):
185
- pass
181
+ class Entitlement(ComplexAttribute):
182
+ value: str | None = None
183
+ """The value of an entitlement."""
186
184
 
185
+ display: str | None = None
186
+ """A human-readable name, primarily used for display purposes."""
187
+
188
+ type: str | None = None
189
+ """A label indicating the attribute's function."""
190
+
191
+ primary: bool | None = None
192
+ """A Boolean value indicating the 'primary' or preferred attribute value
193
+ for this attribute."""
187
194
 
188
- class GroupMembership(MultiValuedComplexAttribute):
189
- value: Annotated[Optional[str], Mutability.read_only] = None
195
+
196
+ class GroupMembership(ComplexAttribute):
197
+ value: Annotated[str | None, Mutability.read_only] = None
190
198
  """The identifier of the User's group."""
191
199
 
192
200
  ref: Annotated[
193
- Optional[Reference[Union[Literal["User"], Literal["Group"]]]],
201
+ Reference[Literal["User"] | Literal["Group"]] | None,
194
202
  Mutability.read_only,
195
203
  ] = Field(None, serialization_alias="$ref")
196
204
  """The reference URI of a target resource, if the attribute is a
197
205
  reference."""
198
206
 
199
- display: Annotated[Optional[str], Mutability.read_only] = None
207
+ display: Annotated[str | None, Mutability.read_only] = None
200
208
  """A human-readable name, primarily used for display purposes."""
201
209
 
202
- type: Annotated[Optional[str], Mutability.read_only] = Field(
210
+ type: Annotated[str | None, Mutability.read_only] = Field(
203
211
  None, examples=["direct", "indirect"]
204
212
  )
205
213
  """A label indicating the attribute's function, e.g., 'direct' or
206
214
  'indirect'."""
207
215
 
208
216
 
209
- class Role(MultiValuedComplexAttribute):
210
- pass
217
+ class Role(ComplexAttribute):
218
+ value: str | None = None
219
+ """The value of a role."""
220
+
221
+ display: str | None = None
222
+ """A human-readable name, primarily used for display purposes."""
211
223
 
224
+ type: str | None = None
225
+ """A label indicating the attribute's function."""
212
226
 
213
- class X509Certificate(MultiValuedComplexAttribute):
214
- value: Annotated[Optional[Base64Bytes], CaseExact.true] = None
227
+ primary: bool | None = None
228
+ """A Boolean value indicating the 'primary' or preferred attribute value
229
+ for this attribute."""
230
+
231
+
232
+ class X509Certificate(ComplexAttribute):
233
+ value: Annotated[Base64Bytes | None, CaseExact.true] = None
215
234
  """The value of an X.509 certificate."""
216
235
 
236
+ display: str | None = None
237
+ """A human-readable name, primarily used for display purposes."""
238
+
239
+ type: str | None = None
240
+ """A label indicating the attribute's function."""
241
+
242
+ primary: bool | None = None
243
+ """A Boolean value indicating the 'primary' or preferred attribute value
244
+ for this attribute."""
245
+
217
246
 
218
247
  class User(Resource[AnyExtension]):
219
248
  schemas: Annotated[list[str], Required.true] = [
220
249
  "urn:ietf:params:scim:schemas:core:2.0:User"
221
250
  ]
222
251
 
223
- user_name: Annotated[Optional[str], Uniqueness.server, Required.true] = None
252
+ user_name: Annotated[str | None, Uniqueness.server, Required.true] = None
224
253
  """Unique identifier for the User, typically used by the user to directly
225
254
  authenticate to the service provider."""
226
255
 
227
- name: Optional[Name] = None
256
+ name: Name | None = None
228
257
  """The components of the user's real name."""
229
258
 
230
259
  Name: ClassVar[type[ComplexAttribute]] = Name
231
260
 
232
- display_name: Optional[str] = None
261
+ display_name: str | None = None
233
262
  """The name of the User, suitable for display to end-users."""
234
263
 
235
- nick_name: Optional[str] = None
264
+ nick_name: str | None = None
236
265
  """The casual way to address the user in real life, e.g., 'Bob' or 'Bobby'
237
266
  instead of 'Robert'."""
238
267
 
239
- profile_url: Optional[Reference[ExternalReference]] = None
268
+ profile_url: Reference[ExternalReference] | None = None
240
269
  """A fully qualified URL pointing to a page representing the User's online
241
270
  profile."""
242
271
 
243
- title: Optional[str] = None
272
+ title: str | None = None
244
273
  """The user's title, such as "Vice President"."""
245
274
 
246
- user_type: Optional[str] = None
275
+ user_type: str | None = None
247
276
  """Used to identify the relationship between the organization and the user.
248
277
 
249
278
  Typical values used might be 'Contractor', 'Employee', 'Intern',
250
279
  'Temp', 'External', and 'Unknown', but any value may be used.
251
280
  """
252
281
 
253
- preferred_language: Optional[str] = None
282
+ preferred_language: str | None = None
254
283
  """Indicates the User's preferred written or spoken language.
255
284
 
256
285
  Generally used for selecting a localized user interface; e.g.,
257
286
  'en_US' specifies the language English and country US.
258
287
  """
259
288
 
260
- locale: Optional[str] = None
289
+ locale: str | None = None
261
290
  """Used to indicate the User's default location for purposes of localizing
262
291
  items such as currency, date time format, or numerical representations."""
263
292
 
264
- timezone: Optional[str] = None
293
+ timezone: str | None = None
265
294
  """The User's time zone in the 'Olson' time zone database format, e.g.,
266
295
  'America/Los_Angeles'."""
267
296
 
268
- active: Optional[bool] = None
297
+ active: bool | None = None
269
298
  """A Boolean value indicating the User's administrative status."""
270
299
 
271
- password: Annotated[Optional[str], Mutability.write_only, Returned.never] = None
300
+ password: Annotated[str | None, Mutability.write_only, Returned.never] = None
272
301
  """The User's cleartext password."""
273
302
 
274
- emails: Optional[list[Email]] = None
303
+ emails: list[Email] | None = None
275
304
  """Email addresses for the user."""
276
305
 
277
306
  Emails: ClassVar[type[ComplexAttribute]] = Email
278
307
 
279
- phone_numbers: Optional[list[PhoneNumber]] = None
308
+ phone_numbers: list[PhoneNumber] | None = None
280
309
  """Phone numbers for the User."""
281
310
 
282
311
  PhoneNumbers: ClassVar[type[ComplexAttribute]] = PhoneNumber
283
312
 
284
- ims: Optional[list[Im]] = None
313
+ ims: list[Im] | None = None
285
314
  """Instant messaging addresses for the User."""
286
315
 
287
316
  Ims: ClassVar[type[ComplexAttribute]] = Im
288
317
 
289
- photos: Optional[list[Photo]] = None
318
+ photos: list[Photo] | None = None
290
319
  """URLs of photos of the User."""
291
320
 
292
321
  Photos: ClassVar[type[ComplexAttribute]] = Photo
293
322
 
294
- addresses: Optional[list[Address]] = None
323
+ addresses: list[Address] | None = None
295
324
  """A physical mailing address for this User."""
296
325
 
297
326
  Addresses: ClassVar[type[ComplexAttribute]] = Address
298
327
 
299
- groups: Annotated[Optional[list[GroupMembership]], Mutability.read_only] = None
328
+ groups: Annotated[list[GroupMembership] | None, Mutability.read_only] = None
300
329
  """A list of groups to which the user belongs, either through direct
301
330
  membership, through nested groups, or dynamically calculated."""
302
331
 
303
332
  Groups: ClassVar[type[ComplexAttribute]] = GroupMembership
304
333
 
305
- entitlements: Optional[list[Entitlement]] = None
334
+ entitlements: list[Entitlement] | None = None
306
335
  """A list of entitlements for the User that represent a thing the User
307
336
  has."""
308
337
 
309
338
  Entitlements: ClassVar[type[ComplexAttribute]] = Entitlement
310
339
 
311
- roles: Optional[list[Role]] = None
340
+ roles: list[Role] | None = None
312
341
  """A list of roles for the User that collectively represent who the User
313
342
  is, e.g., 'Student', 'Faculty'."""
314
343
 
315
344
  Roles: ClassVar[type[ComplexAttribute]] = Role
316
345
 
317
- x509_certificates: Optional[list[X509Certificate]] = None
346
+ x509_certificates: list[X509Certificate] | None = None
318
347
  """A list of certificates issued to the User."""
319
348
 
320
349
  X509Certificates: ClassVar[type[ComplexAttribute]] = X509Certificate
@@ -3,7 +3,6 @@
3
3
  from typing import TYPE_CHECKING
4
4
  from typing import Annotated
5
5
  from typing import Any
6
- from typing import Optional
7
6
 
8
7
  from .annotations import Required
9
8
  from .base import BaseModel
@@ -22,7 +21,7 @@ class ScimObject(BaseModel):
22
21
 
23
22
  def _prepare_model_dump(
24
23
  self,
25
- scim_ctx: Optional[Context] = Context.DEFAULT,
24
+ scim_ctx: Context | None = Context.DEFAULT,
26
25
  **kwargs: Any,
27
26
  ) -> dict[str, Any]:
28
27
  kwargs.setdefault("context", {}).setdefault("scim", scim_ctx)
@@ -36,7 +35,7 @@ class ScimObject(BaseModel):
36
35
  def model_dump(
37
36
  self,
38
37
  *args: Any,
39
- scim_ctx: Optional[Context] = Context.DEFAULT,
38
+ scim_ctx: Context | None = Context.DEFAULT,
40
39
  **kwargs: Any,
41
40
  ) -> dict[str, Any]:
42
41
  """Create a model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump`.
@@ -53,7 +52,7 @@ class ScimObject(BaseModel):
53
52
  def model_dump_json(
54
53
  self,
55
54
  *args: Any,
56
- scim_ctx: Optional[Context] = Context.DEFAULT,
55
+ scim_ctx: Context | None = Context.DEFAULT,
57
56
  **kwargs: Any,
58
57
  ) -> str:
59
58
  """Create a JSON model representation that can be included in SCIM messages by using Pydantic :code:`BaseModel.model_dump_json`.
scim2_models/urn.py CHANGED
@@ -1,9 +1,9 @@
1
1
  from typing import TYPE_CHECKING
2
2
  from typing import Any
3
- from typing import Optional
4
3
  from typing import Union
5
4
 
6
5
  from .base import BaseModel
6
+ from .utils import _get_path_parts
7
7
  from .utils import _normalize_attribute_name
8
8
 
9
9
  if TYPE_CHECKING:
@@ -23,12 +23,22 @@ def _get_or_create_extension_instance(
23
23
  return extension_instance
24
24
 
25
25
 
26
- def _normalize_path(model: Optional[type["BaseModel"]], path: str) -> tuple[str, str]:
26
+ def _normalize_path(model: type["BaseModel"] | None, path: str) -> tuple[str, str]:
27
27
  """Resolve a path to (schema_urn, attribute_path)."""
28
28
  from .resources.resource import Resource
29
29
 
30
30
  # Absolute URN
31
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
+
32
42
  parts = path.rsplit(":", 1)
33
43
  return parts[0], parts[1]
34
44
 
@@ -42,7 +52,7 @@ def _normalize_path(model: Optional[type["BaseModel"]], path: str) -> tuple[str,
42
52
 
43
53
  def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) -> None:
44
54
  """Validate that an attribute name or a sub-attribute path exist for a given model."""
45
- attribute_name, *sub_attribute_blocks = attribute_base.split(".")
55
+ attribute_name, *sub_attribute_blocks = _get_path_parts(attribute_base)
46
56
  sub_attribute_base = ".".join(sub_attribute_blocks)
47
57
 
48
58
  aliases = {field.validation_alias for field in model.model_fields.values()}
@@ -65,7 +75,7 @@ def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) ->
65
75
 
66
76
  def _validate_attribute_urn(
67
77
  attribute_name: str, resource: type["Resource[Any]"]
68
- ) -> Optional[str]:
78
+ ) -> str | None:
69
79
  """Validate that an attribute urn is valid or not.
70
80
 
71
81
  :param attribute_name: The attribute urn to check.
@@ -73,7 +83,7 @@ def _validate_attribute_urn(
73
83
  """
74
84
  from .resources.resource import Resource
75
85
 
76
- schema: Optional[Any]
86
+ schema: Any | None
77
87
  schema, attribute_base = _normalize_path(resource, attribute_name)
78
88
 
79
89
  validated_resource = Resource.get_by_schema([resource], schema)
@@ -90,7 +100,7 @@ def _validate_attribute_urn(
90
100
 
91
101
  def _resolve_path_to_target(
92
102
  resource: "Resource[Any]", path: str
93
- ) -> tuple[Optional[Union["Resource[Any]", "Extension"]], str]:
103
+ ) -> tuple[Union["Resource[Any]", "Extension"] | None, str]:
94
104
  """Resolve a path to a target and an attribute_path.
95
105
 
96
106
  The target can be the resource itself, or an extension object.
@@ -100,12 +110,17 @@ def _resolve_path_to_target(
100
110
  if not schema_urn:
101
111
  return resource, attr_path
102
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
+
103
123
  if schema_urn in resource.schemas:
104
124
  return resource, attr_path
105
125
 
106
- extension_class = resource.get_extension_model(schema_urn)
107
- if not extension_class:
108
- return (None, "")
109
-
110
- extension_instance = _get_or_create_extension_instance(resource, extension_class)
111
- return extension_instance, attr_path
126
+ return (None, "")
scim2_models/utils.py CHANGED
@@ -3,7 +3,6 @@ import re
3
3
  from typing import TYPE_CHECKING
4
4
  from typing import Annotated
5
5
  from typing import Literal
6
- from typing import Optional
7
6
  from typing import Union
8
7
 
9
8
  from pydantic import EncodedBytes
@@ -22,8 +21,12 @@ except ImportError:
22
21
  # Python 3.9 has no UnionType
23
22
  UNION_TYPES = [Union]
24
23
 
24
+ _UNDERSCORE_ALPHANUMERIC = re.compile(r"_+([0-9A-Za-z]+)")
25
+ _NON_WORD_UNDERSCORE = re.compile(r"[\W_]+")
26
+ _VALID_PATH_PATTERN = re.compile(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$')
25
27
 
26
- def _int_to_str(status: Optional[int]) -> Optional[str]:
28
+
29
+ def _int_to_str(status: int | None) -> str | None:
27
30
  return None if status is None else str(status)
28
31
 
29
32
 
@@ -87,7 +90,7 @@ def _to_camel(string: str) -> str:
87
90
  '$ref' stays '$ref'.
88
91
  """
89
92
  snake = to_snake(string)
90
- camel = re.sub(r"_+([0-9A-Za-z]+)", lambda m: m.group(1).title(), snake)
93
+ camel = _UNDERSCORE_ALPHANUMERIC.sub(lambda m: m.group(1).title(), snake)
91
94
  return camel
92
95
 
93
96
 
@@ -98,7 +101,7 @@ def _normalize_attribute_name(attribute_name: str) -> str:
98
101
  """
99
102
  is_extension_attribute = ":" in attribute_name
100
103
  if not is_extension_attribute:
101
- attribute_name = re.sub(r"[\W_]+", "", attribute_name)
104
+ attribute_name = _NON_WORD_UNDERSCORE.sub("", attribute_name)
102
105
 
103
106
  return attribute_name.lower()
104
107
 
@@ -122,7 +125,7 @@ def _validate_scim_path_syntax(path: str) -> bool:
122
125
 
123
126
  # Cannot contain invalid characters (basic check)
124
127
  # Allow alphanumeric, dots, underscores, hyphens, colons (for URNs), brackets
125
- if not re.match(r'^[a-zA-Z][a-zA-Z0-9._:\-\[\]"=\s]*$', path):
128
+ if not _VALID_PATH_PATTERN.match(path):
126
129
  return False
127
130
 
128
131
  # If it contains a colon, validate it's a proper URN format
@@ -158,7 +161,7 @@ def _validate_scim_urn_syntax(path: str) -> bool:
158
161
  return True
159
162
 
160
163
 
161
- def _extract_field_name(path: str) -> Optional[str]:
164
+ def _extract_field_name(path: str) -> str | None:
162
165
  """Extract the field name from a path.
163
166
 
164
167
  For now, only handle simple paths (no filters, no complex expressions).
@@ -181,7 +184,7 @@ def _extract_field_name(path: str) -> Optional[str]:
181
184
  return path
182
185
 
183
186
 
184
- def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> Optional[str]:
187
+ def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> str | None:
185
188
  """Find the actual field name in a resource class from an attribute name.
186
189
 
187
190
  :param resource_class: The resource class to search in
@@ -195,3 +198,7 @@ def _find_field_name(model_class: type["BaseModel"], attr_name: str) -> Optional
195
198
  return field_key
196
199
 
197
200
  return None
201
+
202
+
203
+ def _get_path_parts(path: str) -> list[str]:
204
+ return path.split(".")