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 CHANGED
@@ -7,50 +7,50 @@ from .attributes import ComplexAttribute
7
7
  from .attributes import MultiValuedComplexAttribute
8
8
  from .base import BaseModel
9
9
  from .context import Context
10
+ from .messages.bulk import BulkOperation
11
+ from .messages.bulk import BulkRequest
12
+ from .messages.bulk import BulkResponse
13
+ from .messages.error import Error
14
+ from .messages.list_response import ListResponse
15
+ from .messages.message import Message
16
+ from .messages.patch_op import PatchOp
17
+ from .messages.patch_op import PatchOperation
18
+ from .messages.search_request import SearchRequest
10
19
  from .reference import ExternalReference
11
20
  from .reference import Reference
12
21
  from .reference import URIReference
13
- from .rfc7643.enterprise_user import EnterpriseUser
14
- from .rfc7643.enterprise_user import Manager
15
- from .rfc7643.group import Group
16
- from .rfc7643.group import GroupMember
17
- from .rfc7643.resource import AnyExtension
18
- from .rfc7643.resource import AnyResource
19
- from .rfc7643.resource import Extension
20
- from .rfc7643.resource import Meta
21
- from .rfc7643.resource import Resource
22
- from .rfc7643.resource_type import ResourceType
23
- from .rfc7643.resource_type import SchemaExtension
24
- from .rfc7643.schema import Attribute
25
- from .rfc7643.schema import Schema
26
- from .rfc7643.service_provider_config import AuthenticationScheme
27
- from .rfc7643.service_provider_config import Bulk
28
- from .rfc7643.service_provider_config import ChangePassword
29
- from .rfc7643.service_provider_config import ETag
30
- from .rfc7643.service_provider_config import Filter
31
- from .rfc7643.service_provider_config import Patch
32
- from .rfc7643.service_provider_config import ServiceProviderConfig
33
- from .rfc7643.service_provider_config import Sort
34
- from .rfc7643.user import Address
35
- from .rfc7643.user import Email
36
- from .rfc7643.user import Entitlement
37
- from .rfc7643.user import GroupMembership
38
- from .rfc7643.user import Im
39
- from .rfc7643.user import Name
40
- from .rfc7643.user import PhoneNumber
41
- from .rfc7643.user import Photo
42
- from .rfc7643.user import Role
43
- from .rfc7643.user import User
44
- from .rfc7643.user import X509Certificate
45
- from .rfc7644.bulk import BulkOperation
46
- from .rfc7644.bulk import BulkRequest
47
- from .rfc7644.bulk import BulkResponse
48
- from .rfc7644.error import Error
49
- from .rfc7644.list_response import ListResponse
50
- from .rfc7644.message import Message
51
- from .rfc7644.patch_op import PatchOp
52
- from .rfc7644.patch_op import PatchOperation
53
- from .rfc7644.search_request import SearchRequest
22
+ from .resources.enterprise_user import EnterpriseUser
23
+ from .resources.enterprise_user import Manager
24
+ from .resources.group import Group
25
+ from .resources.group import GroupMember
26
+ from .resources.resource import AnyExtension
27
+ from .resources.resource import AnyResource
28
+ from .resources.resource import Extension
29
+ from .resources.resource import Meta
30
+ from .resources.resource import Resource
31
+ from .resources.resource_type import ResourceType
32
+ from .resources.resource_type import SchemaExtension
33
+ from .resources.schema import Attribute
34
+ from .resources.schema import Schema
35
+ from .resources.service_provider_config import AuthenticationScheme
36
+ from .resources.service_provider_config import Bulk
37
+ from .resources.service_provider_config import ChangePassword
38
+ from .resources.service_provider_config import ETag
39
+ from .resources.service_provider_config import Filter
40
+ from .resources.service_provider_config import Patch
41
+ from .resources.service_provider_config import ServiceProviderConfig
42
+ from .resources.service_provider_config import Sort
43
+ from .resources.user import Address
44
+ from .resources.user import Email
45
+ from .resources.user import Entitlement
46
+ from .resources.user import GroupMembership
47
+ from .resources.user import Im
48
+ from .resources.user import Name
49
+ from .resources.user import PhoneNumber
50
+ from .resources.user import Photo
51
+ from .resources.user import Role
52
+ from .resources.user import User
53
+ from .resources.user import X509Certificate
54
54
 
55
55
  __all__ = [
56
56
  "Address",
@@ -16,7 +16,7 @@ from .reference import Reference
16
16
  class ComplexAttribute(BaseModel):
17
17
  """A complex attribute as defined in :rfc:`RFC7643 §2.3.8 <7643#section-2.3.8>`."""
18
18
 
19
- attribute_urn: Optional[str] = Field(None, exclude=True)
19
+ _attribute_urn: Optional[str] = None
20
20
 
21
21
  def get_attribute_urn(self, field_name: str) -> str:
22
22
  """Build the full URN of the attribute.
@@ -26,7 +26,7 @@ class ComplexAttribute(BaseModel):
26
26
  alias = (
27
27
  self.__class__.model_fields[field_name].serialization_alias or field_name
28
28
  )
29
- return f"{self.attribute_urn}.{alias}"
29
+ return f"{self._attribute_urn}.{alias}"
30
30
 
31
31
 
32
32
  class MultiValuedComplexAttribute(ComplexAttribute):
scim2_models/base.py CHANGED
@@ -23,13 +23,13 @@ from scim2_models.annotations import Mutability
23
23
  from scim2_models.annotations import Required
24
24
  from scim2_models.annotations import Returned
25
25
  from scim2_models.context import Context
26
- from scim2_models.utils import normalize_attribute_name
27
- from scim2_models.utils import to_camel
26
+ from scim2_models.utils import UNION_TYPES
27
+ from scim2_models.utils import _find_field_name
28
+ from scim2_models.utils import _normalize_attribute_name
29
+ from scim2_models.utils import _to_camel
28
30
 
29
- from .utils import UNION_TYPES
30
31
 
31
-
32
- def contains_attribute_or_subattributes(
32
+ def _contains_attribute_or_subattributes(
33
33
  attribute_urns: list[str], attribute_urn: str
34
34
  ) -> bool:
35
35
  return attribute_urn in attribute_urns or any(
@@ -43,8 +43,8 @@ class BaseModel(PydanticBaseModel):
43
43
 
44
44
  model_config = ConfigDict(
45
45
  alias_generator=AliasGenerator(
46
- validation_alias=normalize_attribute_name,
47
- serialization_alias=to_camel,
46
+ validation_alias=_normalize_attribute_name,
47
+ serialization_alias=_to_camel,
48
48
  ),
49
49
  validate_assignment=True,
50
50
  populate_by_name=True,
@@ -54,7 +54,35 @@ class BaseModel(PydanticBaseModel):
54
54
 
55
55
  @classmethod
56
56
  def get_field_annotation(cls, field_name: str, annotation_type: type) -> Any:
57
- """Return the annotation of type 'annotation_type' of the field 'field_name'."""
57
+ """Return the annotation of type 'annotation_type' of the field 'field_name'.
58
+
59
+ This method extracts SCIM-specific annotations from a field's metadata,
60
+ such as :class:`~scim2_models.Mutability`, :class:`~scim2_models.Required`,
61
+ or :class:`~scim2_models.Returned` annotations.
62
+
63
+ :return: The annotation instance if found, otherwise the annotation type's default value
64
+
65
+ >>> from scim2_models.resources.user import User
66
+ >>> from scim2_models.annotations import Mutability, Required
67
+
68
+ Get the mutability annotation of the 'id' field:
69
+
70
+ >>> mutability = User.get_field_annotation("id", Mutability)
71
+ >>> mutability
72
+ <Mutability.read_only: 'readOnly'>
73
+
74
+ Get the required annotation of the 'user_name' field:
75
+
76
+ >>> required = User.get_field_annotation("user_name", Required)
77
+ >>> required
78
+ <Required.true: True>
79
+
80
+ If no annotation is found, returns the default value:
81
+
82
+ >>> missing = User.get_field_annotation("display_name", Required)
83
+ >>> missing
84
+ <Required.false: False>
85
+ """
58
86
  field_metadata = cls.model_fields[field_name].metadata
59
87
 
60
88
  default_value = getattr(annotation_type, "_default", None)
@@ -71,8 +99,34 @@ class BaseModel(PydanticBaseModel):
71
99
  def get_field_root_type(cls, attribute_name: str) -> Optional[type]:
72
100
  """Extract the root type from a model field.
73
101
 
74
- For example, return 'GroupMember' for
75
- 'Optional[List[GroupMember]]'
102
+ This method unwraps complex type annotations to find the underlying
103
+ type, removing Optional and List wrappers to get to the actual type
104
+ of the field's content.
105
+
106
+ :return: The root type of the field, or None if not found
107
+
108
+ >>> from scim2_models.resources.user import User
109
+ >>> from scim2_models.resources.group import Group
110
+
111
+ Simple type:
112
+
113
+ >>> User.get_field_root_type("user_name")
114
+ <class 'str'>
115
+
116
+ ``Optional`` type unwraps to the underlying type:
117
+
118
+ >>> User.get_field_root_type("display_name")
119
+ <class 'str'>
120
+
121
+ ``List`` type unwraps to the element type:
122
+
123
+ >>> User.get_field_root_type("emails") # doctest: +ELLIPSIS
124
+ <class 'scim2_models.resources.user.Email'>
125
+
126
+ ``Optional[List[T]]`` unwraps to ``T``:
127
+
128
+ >>> Group.get_field_root_type("members") # doctest: +ELLIPSIS
129
+ <class 'scim2_models.resources.group.GroupMember'>
76
130
  """
77
131
  attribute_type = cls.model_fields[attribute_name].annotation
78
132
 
@@ -89,7 +143,20 @@ class BaseModel(PydanticBaseModel):
89
143
 
90
144
  @classmethod
91
145
  def get_field_multiplicity(cls, attribute_name: str) -> bool:
92
- """Indicate whether a field holds multiple values."""
146
+ """Indicate whether a field holds multiple values.
147
+
148
+ This method determines if a field is defined as a list type,
149
+ which indicates it can contain multiple values. It handles
150
+ Optional wrappers correctly.
151
+
152
+ :return: True if the field holds multiple values (is a list), False otherwise
153
+
154
+ >>> from scim2_models.resources.user import User
155
+ >>> User.get_field_multiplicity("user_name")
156
+ False
157
+ >>> User.get_field_multiplicity("emails")
158
+ True
159
+ """
93
160
  attribute_type = cls.model_fields[attribute_name].annotation
94
161
 
95
162
  # extract 'x' from 'Optional[x]'
@@ -104,7 +171,7 @@ class BaseModel(PydanticBaseModel):
104
171
  def check_request_attributes_mutability(
105
172
  cls, value: Any, info: ValidationInfo
106
173
  ) -> Any:
107
- """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7653#section-7>`."""
174
+ """Check and fix that the field mutability is expected according to the requests validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`."""
108
175
  if (
109
176
  not info.context
110
177
  or not info.field_name
@@ -147,20 +214,49 @@ class BaseModel(PydanticBaseModel):
147
214
  ) -> Self:
148
215
  """Normalize payload attribute names.
149
216
 
150
- :rfc:`RFC7643 §2.1 <7653#section-2.1>` indicate that attribute
217
+ :rfc:`RFC7643 §2.1 <7643#section-2.1>` indicate that attribute
151
218
  names should be case-insensitive. Any attribute name is
152
219
  transformed in lowercase so any case is handled the same way.
153
220
  """
154
221
 
155
- def normalize_value(value: Any) -> Any:
156
- if isinstance(value, dict):
157
- return {
158
- normalize_attribute_name(k): normalize_value(v)
159
- for k, v in value.items()
160
- }
161
- return value
222
+ def normalize_dict_keys(
223
+ input_dict: dict, model_class: type["BaseModel"]
224
+ ) -> dict:
225
+ """Normalize dictionary keys, preserving case for Any fields."""
226
+ result = {}
162
227
 
163
- normalized_value = normalize_value(value)
228
+ for key, val in input_dict.items():
229
+ field_name = _find_field_name(model_class, key)
230
+ field_type = (
231
+ model_class.get_field_root_type(field_name) if field_name else None
232
+ )
233
+
234
+ # Don't normalize keys for attributes typed with Any
235
+ # This way, agnostic dicts such as PatchOp.operations.value
236
+ # are preserved
237
+ if field_name and field_type == Any:
238
+ result[key] = normalize_value(val)
239
+ else:
240
+ result[_normalize_attribute_name(key)] = normalize_value(
241
+ val, field_type
242
+ )
243
+
244
+ return result
245
+
246
+ def normalize_value(
247
+ val: Any, model_class: Optional[type["BaseModel"]] = None
248
+ ) -> Any:
249
+ """Normalize input value based on model class."""
250
+ if not isinstance(val, dict):
251
+ return val
252
+
253
+ # If no model_class, preserve original keys
254
+ if not model_class:
255
+ return {k: normalize_value(v) for k, v in val.items()}
256
+
257
+ return normalize_dict_keys(val, model_class)
258
+
259
+ normalized_value = normalize_value(value, cls)
164
260
  obj = handler(normalized_value)
165
261
  assert isinstance(obj, cls)
166
262
  return obj
@@ -170,7 +266,7 @@ class BaseModel(PydanticBaseModel):
170
266
  def check_response_attributes_returnability(
171
267
  cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
172
268
  ) -> Self:
173
- """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7653#section-7>`."""
269
+ """Check that the fields returnability is expected according to the responses validation context, as defined in :rfc:`RFC7643 §7 <7643#section-7>`."""
174
270
  obj = handler(value)
175
271
  assert isinstance(obj, cls)
176
272
 
@@ -244,7 +340,7 @@ class BaseModel(PydanticBaseModel):
244
340
  cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
245
341
  ) -> Self:
246
342
  """Check if 'immutable' attributes have been mutated in replacement requests."""
247
- from scim2_models.rfc7643.resource import Resource
343
+ from scim2_models.resources.resource import Resource
248
344
 
249
345
  obj = handler(value)
250
346
  assert isinstance(obj, cls)
@@ -256,11 +352,11 @@ class BaseModel(PydanticBaseModel):
256
352
  and issubclass(cls, Resource)
257
353
  and original is not None
258
354
  ):
259
- cls.check_mutability_issues(original, obj)
355
+ cls._check_mutability_issues(original, obj)
260
356
  return obj
261
357
 
262
358
  @classmethod
263
- def check_mutability_issues(
359
+ def _check_mutability_issues(
264
360
  cls, original: "BaseModel", replacement: "BaseModel"
265
361
  ) -> None:
266
362
  """Compare two instances, and check for differences of values on the fields marked as immutable."""
@@ -287,18 +383,18 @@ class BaseModel(PydanticBaseModel):
287
383
  original_val = getattr(original, field_name)
288
384
  replacement_value = getattr(replacement, field_name)
289
385
  if original_val is not None and replacement_value is not None:
290
- cls.check_mutability_issues(original_val, replacement_value)
386
+ cls._check_mutability_issues(original_val, replacement_value)
291
387
 
292
- def set_complex_attribute_urns(self) -> None:
293
- """Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a 'attribute_urn' attribute.
388
+ def _set_complex_attribute_urns(self) -> None:
389
+ """Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_attribute_urn' attribute.
294
390
 
295
- 'attribute_urn' will later be used by 'get_attribute_urn'.
391
+ '_attribute_urn' will later be used by 'get_attribute_urn'.
296
392
  """
297
393
  from .attributes import ComplexAttribute
298
394
  from .attributes import is_complex_attribute
299
395
 
300
396
  if isinstance(self, ComplexAttribute):
301
- main_schema = self.attribute_urn
397
+ main_schema = self._attribute_urn
302
398
  separator = "."
303
399
  else:
304
400
  main_schema = self.__class__.model_fields["schemas"].default[0]
@@ -314,9 +410,9 @@ class BaseModel(PydanticBaseModel):
314
410
  if attr_value := getattr(self, field_name):
315
411
  if isinstance(attr_value, list):
316
412
  for item in attr_value:
317
- item.attribute_urn = schema
413
+ item._attribute_urn = schema
318
414
  else:
319
- attr_value.attribute_urn = schema
415
+ attr_value._attribute_urn = schema
320
416
 
321
417
  @field_serializer("*", mode="wrap")
322
418
  def scim_serializer(
@@ -330,14 +426,14 @@ class BaseModel(PydanticBaseModel):
330
426
  scim_ctx = info.context.get("scim") if info.context else None
331
427
 
332
428
  if scim_ctx and Context.is_request(scim_ctx):
333
- value = self.scim_request_serializer(value, info)
429
+ value = self._scim_request_serializer(value, info)
334
430
 
335
431
  if scim_ctx and Context.is_response(scim_ctx):
336
- value = self.scim_response_serializer(value, info)
432
+ value = self._scim_response_serializer(value, info)
337
433
 
338
434
  return value
339
435
 
340
- def scim_request_serializer(self, value: Any, info: FieldSerializationInfo) -> Any:
436
+ def _scim_request_serializer(self, value: Any, info: FieldSerializationInfo) -> Any:
341
437
  """Serialize the fields according to mutability indications passed in the serialization context."""
342
438
  mutability = self.get_field_annotation(info.field_name, Mutability)
343
439
  scim_ctx = info.context.get("scim") if info.context else None
@@ -361,7 +457,9 @@ class BaseModel(PydanticBaseModel):
361
457
 
362
458
  return value
363
459
 
364
- def scim_response_serializer(self, value: Any, info: FieldSerializationInfo) -> Any:
460
+ def _scim_response_serializer(
461
+ self, value: Any, info: FieldSerializationInfo
462
+ ) -> Any:
365
463
  """Serialize the fields according to returnability indications passed in the serialization context."""
366
464
  returnability = self.get_field_annotation(info.field_name, Returned)
367
465
  attribute_urn = self.get_attribute_urn(info.field_name)
@@ -370,9 +468,9 @@ class BaseModel(PydanticBaseModel):
370
468
  info.context.get("scim_excluded_attributes", []) if info.context else []
371
469
  )
372
470
 
373
- attribute_urn = normalize_attribute_name(attribute_urn)
374
- included_urns = [normalize_attribute_name(urn) for urn in included_urns]
375
- excluded_urns = [normalize_attribute_name(urn) for urn in excluded_urns]
471
+ attribute_urn = _normalize_attribute_name(attribute_urn)
472
+ included_urns = [_normalize_attribute_name(urn) for urn in included_urns]
473
+ excluded_urns = [_normalize_attribute_name(urn) for urn in excluded_urns]
376
474
 
377
475
  if returnability == Returned.never:
378
476
  return None
@@ -380,7 +478,7 @@ class BaseModel(PydanticBaseModel):
380
478
  if returnability == Returned.default and (
381
479
  (
382
480
  included_urns
383
- and not contains_attribute_or_subattributes(
481
+ and not _contains_attribute_or_subattributes(
384
482
  included_urns, attribute_urn
385
483
  )
386
484
  )
@@ -398,7 +496,7 @@ class BaseModel(PydanticBaseModel):
398
496
  self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
399
497
  ) -> dict[str, Any]:
400
498
  """Remove `None` values inserted by the :meth:`~scim2_models.base.BaseModel.scim_serializer`."""
401
- self.set_complex_attribute_urns()
499
+ self._set_complex_attribute_urns()
402
500
  result = handler(self)
403
501
  return {key: value for key, value in result.items() if value is not None}
404
502
 
@@ -433,7 +531,7 @@ class BaseModel(PydanticBaseModel):
433
531
 
434
532
  See :rfc:`RFC7644 §3.10 <7644#section-3.10>`.
435
533
  """
436
- from scim2_models.rfc7643.resource import Extension
534
+ from scim2_models.resources.resource import Extension
437
535
 
438
536
  main_schema = self.__class__.model_fields["schemas"].default[0]
439
537
  field = self.__class__.model_fields[field_name]
scim2_models/context.py CHANGED
@@ -78,7 +78,7 @@ class Context(Enum):
78
78
 
79
79
  - When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
80
80
  - When used for validation, it will ignore attributes annotated with :attr:`scim2_models.Mutability.read_only` and raise a :class:`~pydantic.ValidationError`:
81
- - when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable` different than :paramref:`~scim2_models.BaseModel.model_validate.original`:
81
+ - when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable` different than the ``original`` parameter passed to :meth:`~scim2_models.BaseModel.model_validate`;
82
82
  - when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing on null.
83
83
  """
84
84
 
@@ -8,7 +8,7 @@ from pydantic import PlainSerializer
8
8
 
9
9
  from ..annotations import Required
10
10
  from ..attributes import ComplexAttribute
11
- from ..utils import int_to_str
11
+ from ..utils import _int_to_str
12
12
  from .message import Message
13
13
 
14
14
 
@@ -42,7 +42,7 @@ class BulkOperation(ComplexAttribute):
42
42
  response: Optional[Any] = None
43
43
  """The HTTP response body for the specified request operation."""
44
44
 
45
- status: Annotated[Optional[int], PlainSerializer(int_to_str)] = None
45
+ status: Annotated[Optional[int], PlainSerializer(_int_to_str)] = None
46
46
  """The HTTP response status code for the requested operation."""
47
47
 
48
48
 
@@ -4,7 +4,7 @@ from typing import Optional
4
4
  from pydantic import PlainSerializer
5
5
 
6
6
  from ..annotations import Required
7
- from ..utils import int_to_str
7
+ from ..utils import _int_to_str
8
8
  from .message import Message
9
9
 
10
10
 
@@ -15,7 +15,7 @@ class Error(Message):
15
15
  "urn:ietf:params:scim:api:messages:2.0:Error"
16
16
  ]
17
17
 
18
- status: Annotated[Optional[int], PlainSerializer(int_to_str)] = None
18
+ status: Annotated[Optional[int], PlainSerializer(_int_to_str)] = None
19
19
  """The HTTP status code (see Section 6 of [RFC7231]) expressed as a JSON
20
20
  string."""
21
21
 
@@ -12,12 +12,12 @@ from typing_extensions import Self
12
12
 
13
13
  from ..annotations import Required
14
14
  from ..context import Context
15
- from ..rfc7643.resource import AnyResource
16
- from .message import GenericMessageMetaclass
15
+ from ..resources.resource import AnyResource
17
16
  from .message import Message
17
+ from .message import _GenericMessageMetaclass
18
18
 
19
19
 
20
- class ListResponse(Message, Generic[AnyResource], metaclass=GenericMessageMetaclass):
20
+ class ListResponse(Message, Generic[AnyResource], metaclass=_GenericMessageMetaclass):
21
21
  schemas: Annotated[list[str], Required.true] = [
22
22
  "urn:ietf:params:scim:api:messages:2.0:ListResponse"
23
23
  ]
@@ -10,6 +10,8 @@ from pydantic import Discriminator
10
10
  from pydantic import Tag
11
11
  from pydantic._internal._model_construction import ModelMetaclass
12
12
 
13
+ from scim2_models.resources.resource import Resource
14
+
13
15
  from ..base import BaseModel
14
16
  from ..scim_object import ScimObject
15
17
  from ..utils import UNION_TYPES
@@ -19,7 +21,7 @@ class Message(ScimObject):
19
21
  """SCIM protocol messages as defined by :rfc:`RFC7644 §3.1 <7644#section-3.1>`."""
20
22
 
21
23
 
22
- def create_schema_discriminator(
24
+ def _create_schema_discriminator(
23
25
  resource_types_schemas: list[str],
24
26
  ) -> Callable[[Any], Optional[str]]:
25
27
  """Create a schema discriminator function for the given resource schemas.
@@ -49,7 +51,7 @@ def create_schema_discriminator(
49
51
  return get_schema_from_payload
50
52
 
51
53
 
52
- def get_tag(resource_type: type[BaseModel]) -> Tag:
54
+ def _get_tag(resource_type: type[BaseModel]) -> Tag:
53
55
  """Create Pydantic tag from resource type schema.
54
56
 
55
57
  :param resource_type: SCIM resource type
@@ -58,7 +60,7 @@ def get_tag(resource_type: type[BaseModel]) -> Tag:
58
60
  return Tag(resource_type.model_fields["schemas"].default[0])
59
61
 
60
62
 
61
- def create_tagged_resource_union(resource_union: Any) -> Any:
63
+ def _create_tagged_resource_union(resource_union: Any) -> Any:
62
64
  """Build Discriminated Unions for SCIM resources.
63
65
 
64
66
  Creates discriminated unions so Pydantic can determine which class to instantiate
@@ -79,11 +81,11 @@ def create_tagged_resource_union(resource_union: Any) -> Any:
79
81
  ]
80
82
 
81
83
  # Create discriminator function with schemas captured in closure
82
- schema_discriminator = create_schema_discriminator(resource_types_schemas)
84
+ schema_discriminator = _create_schema_discriminator(resource_types_schemas)
83
85
  discriminator = Discriminator(schema_discriminator)
84
86
 
85
87
  tagged_resources = [
86
- Annotated[resource_type, get_tag(resource_type)]
88
+ Annotated[resource_type, _get_tag(resource_type)]
87
89
  for resource_type in resource_types
88
90
  ]
89
91
  # Dynamic union construction from tuple - MyPy can't validate this at compile time
@@ -91,7 +93,7 @@ def create_tagged_resource_union(resource_union: Any) -> Any:
91
93
  return Annotated[union, discriminator]
92
94
 
93
95
 
94
- class GenericMessageMetaclass(ModelMetaclass):
96
+ class _GenericMessageMetaclass(ModelMetaclass):
95
97
  """Metaclass for SCIM generic types with discriminated unions."""
96
98
 
97
99
  def __new__(
@@ -101,10 +103,17 @@ class GenericMessageMetaclass(ModelMetaclass):
101
103
  if kwargs.get("__pydantic_generic_metadata__") and kwargs[
102
104
  "__pydantic_generic_metadata__"
103
105
  ].get("args"):
104
- tagged_union = create_tagged_resource_union(
106
+ tagged_union = _create_tagged_resource_union(
105
107
  kwargs["__pydantic_generic_metadata__"]["args"][0]
106
108
  )
107
109
  kwargs["__pydantic_generic_metadata__"]["args"] = (tagged_union,)
108
110
 
109
111
  klass = super().__new__(cls, name, bases, attrs, **kwargs)
110
112
  return klass
113
+
114
+
115
+ def _get_resource_class(obj) -> Optional[type[Resource]]:
116
+ """Extract the resource class from generic type parameter."""
117
+ metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", {"args": [None]})
118
+ resource_class = metadata["args"][0]
119
+ return resource_class