scim2-models 0.4.0__tar.gz → 0.4.2__tar.gz

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 (120) hide show
  1. {scim2_models-0.4.0 → scim2_models-0.4.2}/.pre-commit-config.yaml +3 -2
  2. {scim2_models-0.4.0 → scim2_models-0.4.2}/PKG-INFO +1 -1
  3. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/changelog.rst +14 -0
  4. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/conf.py +8 -0
  5. {scim2_models-0.4.0 → scim2_models-0.4.2}/pyproject.toml +2 -1
  6. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/attributes.py +1 -1
  7. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/base.py +2 -2
  8. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/message.py +2 -2
  9. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/patch_op.py +86 -41
  10. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/search_request.py +0 -2
  11. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/reference.py +0 -2
  12. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/group.py +2 -1
  13. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/resource.py +7 -7
  14. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/resource_type.py +3 -2
  15. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/schema.py +1 -1
  16. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/service_provider_config.py +2 -1
  17. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/urn.py +7 -5
  18. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/utils.py +9 -13
  19. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_patch_op_validation.py +35 -0
  20. {scim2_models-0.4.0 → scim2_models-0.4.2}/uv.lock +167 -139
  21. {scim2_models-0.4.0 → scim2_models-0.4.2}/.github/FUNDING.yml +0 -0
  22. {scim2_models-0.4.0 → scim2_models-0.4.2}/.github/workflows/release.yml +0 -0
  23. {scim2_models-0.4.0 → scim2_models-0.4.2}/.github/workflows/tests.yaml +0 -0
  24. {scim2_models-0.4.0 → scim2_models-0.4.2}/.gitignore +0 -0
  25. {scim2_models-0.4.0 → scim2_models-0.4.2}/.readthedocs.yml +0 -0
  26. {scim2_models-0.4.0 → scim2_models-0.4.2}/LICENSE +0 -0
  27. {scim2_models-0.4.0 → scim2_models-0.4.2}/README.md +0 -0
  28. {scim2_models-0.4.0 → scim2_models-0.4.2}/conftest.py +0 -0
  29. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/__init__.py +0 -0
  30. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/contributing.rst +0 -0
  31. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/index.rst +0 -0
  32. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/reference.rst +0 -0
  33. {scim2_models-0.4.0 → scim2_models-0.4.2}/doc/tutorial.rst +0 -0
  34. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.1-user-minimal.json +0 -0
  35. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.2-user-full.json +0 -0
  36. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.3-enterprise_user.json +0 -0
  37. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.4-group.json +0 -0
  38. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.5-service_provider_configuration.json +0 -0
  39. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.6-resource_type-group.json +0 -0
  40. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.6-resource_type-user.json +0 -0
  41. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.1-schema-enterprise_user.json +0 -0
  42. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.1-schema-group.json +0 -0
  43. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.1-schema-user.json +0 -0
  44. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.2-schema-resource_type.json +0 -0
  45. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.2-schema-schema.json +0 -0
  46. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +0 -0
  47. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.12-error-bad_request.json +0 -0
  48. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.12-error-not_found.json +0 -0
  49. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.14-user-post_request.json +0 -0
  50. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.14-user-post_response.json +0 -0
  51. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.3-user-post_request.json +0 -0
  52. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.3-user-post_response.json +0 -0
  53. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.4.1-user-known-resource.json +0 -0
  54. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.4.2-list_response-partial_attributes.json +0 -0
  55. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.4.3-list_response-post_query.json +0 -0
  56. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.4.3-search_request.json +0 -0
  57. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.1-user-put_request.json +0 -0
  58. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.1-user-put_response.json +0 -0
  59. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.1-patch_op-add_emails.json +0 -0
  60. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.1-patch_op-add_members.json +0 -0
  61. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.2-patch_op-remove_all_members.json +0 -0
  62. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.2-patch_op-remove_and_add_one_member.json +0 -0
  63. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.2-patch_op-remove_multi_complex_value.json +0 -0
  64. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.2-patch_op-remove_one_member.json +0 -0
  65. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.3-patch_op-replace_all_email_values.json +0 -0
  66. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.3-patch_op-replace_all_members.json +0 -0
  67. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.3-patch_op-replace_street_address.json +0 -0
  68. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.5.2.3-patch_op-replace_user_work_address.json +0 -0
  69. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.6-error-not_found.json +0 -0
  70. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.1-bulk_request-circular_conflict.json +0 -0
  71. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.1-list_response-circular_reference.json +0 -0
  72. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.2-bulk_request-enterprise_user.json +0 -0
  73. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.2-bulk_request-temporary_identifier.json +0 -0
  74. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.2-bulk_response-temporary_identifier.json +0 -0
  75. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json +0 -0
  76. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.3-bulk_response-error_invalid_syntax.json +0 -0
  77. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.3-bulk_response-multiple_errors.json +0 -0
  78. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.3-bulk_response-multiple_operations.json +0 -0
  79. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.3-error-invalid_syntax.json +0 -0
  80. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.7.4-error-payload_too_large.json +0 -0
  81. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-3.9-user-partial_response.json +0 -0
  82. {scim2_models-0.4.0 → scim2_models-0.4.2}/samples/rfc7644-4-list_response-resource_types.json +0 -0
  83. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/__init__.py +0 -0
  84. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/annotations.py +0 -0
  85. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/constants.py +0 -0
  86. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/context.py +0 -0
  87. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/__init__.py +0 -0
  88. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/bulk.py +0 -0
  89. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/error.py +0 -0
  90. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/messages/list_response.py +0 -0
  91. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/py.typed +0 -0
  92. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/__init__.py +0 -0
  93. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/enterprise_user.py +0 -0
  94. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/resources/user.py +0 -0
  95. {scim2_models-0.4.0 → scim2_models-0.4.2}/scim2_models/scim_object.py +0 -0
  96. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/__init__.py +0 -0
  97. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/conftest.py +0 -0
  98. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_dynamic_resources.py +0 -0
  99. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_dynamic_schemas.py +0 -0
  100. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_enterprise_user.py +0 -0
  101. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_errors.py +0 -0
  102. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_group.py +0 -0
  103. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_list_response.py +0 -0
  104. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_model_attributes.py +0 -0
  105. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_model_serialization.py +0 -0
  106. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_model_validation.py +0 -0
  107. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_models.py +0 -0
  108. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_patch_op_add.py +0 -0
  109. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_patch_op_extensions.py +0 -0
  110. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_patch_op_remove.py +0 -0
  111. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_patch_op_replace.py +0 -0
  112. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_path_validation.py +0 -0
  113. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_reference.py +0 -0
  114. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_resource_extension.py +0 -0
  115. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_resource_type.py +0 -0
  116. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_schema.py +0 -0
  117. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_search_request.py +0 -0
  118. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_service_provider_configuration.py +0 -0
  119. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_user.py +0 -0
  120. {scim2_models-0.4.0 → scim2_models-0.4.2}/tests/test_utils.py +0 -0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  repos:
3
3
  - repo: https://github.com/astral-sh/ruff-pre-commit
4
- rev: 'v0.12.4'
4
+ rev: 'v0.12.7'
5
5
  hooks:
6
6
  - id: ruff
7
7
  args: [--fix, --exit-non-zero-on-fix]
@@ -16,9 +16,10 @@ repos:
16
16
  exclude: "\\.svg$|\\.map$|\\.min\\.css$|\\.min\\.js$|\\.po$|\\.pot$"
17
17
  - id: check-toml
18
18
  - repo: https://github.com/pre-commit/mirrors-mypy
19
- rev: v1.17.0
19
+ rev: v1.17.1
20
20
  hooks:
21
21
  - id: mypy
22
+ exclude: ^(tests/|conftest.py)
22
23
  additional_dependencies:
23
24
  - pydantic[email]>=2.7.0
24
25
  - repo: https://github.com/codespell-project/codespell
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scim2-models
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -1,6 +1,20 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ [0.4.2] - 2025-08-05
5
+ --------------------
6
+
7
+ Fixed
8
+ ^^^^^
9
+ - The library is 100% typed with mypy strict.
10
+
11
+ [0.4.1] - 2025-07-23
12
+ --------------------
13
+
14
+ Fixed
15
+ ^^^^^
16
+ - Allow ``TypeVar`` as type parameters for :class:`~scim2_models.PatchOp`.
17
+
4
18
  [0.4.0] - 2025-07-23
5
19
  --------------------
6
20
 
@@ -62,6 +62,14 @@ html_theme_options = {
62
62
  "title": "scim2-cli",
63
63
  "url": "https://scim2-cli.readthedocs.io",
64
64
  },
65
+ {
66
+ "title": "scim2-server",
67
+ "url": "https://github.com/python-scim/scim2-server",
68
+ },
69
+ {
70
+ "title": "pytest-scim2-server",
71
+ "url": "https://github.com/pytest-dev/pytest-scim2-server",
72
+ },
65
73
  ],
66
74
  }
67
75
  html_context = {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "scim2-models"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "SCIM2 models serialization and validation with pydantic"
9
9
  authors = [{name="Yaal Coop", email="contact@yaal.coop"}]
10
10
  license = {file = "LICENSE"}
@@ -114,6 +114,7 @@ exclude = [
114
114
  "tests/",
115
115
  "conftest.py",
116
116
  ]
117
+ strict=true
117
118
 
118
119
  [tool.pydantic-mypy]
119
120
  init_forbid_extra = true
@@ -43,7 +43,7 @@ class MultiValuedComplexAttribute(ComplexAttribute):
43
43
  value: Optional[Any] = None
44
44
  """The value of an entitlement."""
45
45
 
46
- ref: Optional[Reference] = Field(None, serialization_alias="$ref")
46
+ ref: Optional[Reference[Any]] = Field(None, serialization_alias="$ref")
47
47
  """The reference URI of a target resource, if the attribute is a
48
48
  reference."""
49
49
 
@@ -220,8 +220,8 @@ class BaseModel(PydanticBaseModel):
220
220
  """
221
221
 
222
222
  def normalize_dict_keys(
223
- input_dict: dict, model_class: type["BaseModel"]
224
- ) -> dict:
223
+ input_dict: dict[str, Any], model_class: type["BaseModel"]
224
+ ) -> dict[str, Any]:
225
225
  """Normalize dictionary keys, preserving case for Any fields."""
226
226
  result = {}
227
227
 
@@ -112,8 +112,8 @@ class _GenericMessageMetaclass(ModelMetaclass):
112
112
  return klass
113
113
 
114
114
 
115
- def _get_resource_class(obj) -> Optional[type[Resource]]:
115
+ def _get_resource_class(obj: BaseModel) -> type[Resource[Any]]:
116
116
  """Extract the resource class from generic type parameter."""
117
117
  metadata = getattr(obj.__class__, "__pydantic_generic_metadata__", {"args": [None]})
118
118
  resource_class = metadata["args"][0]
119
- return resource_class
119
+ return resource_class # type: ignore[no-any-return]
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from typing import Generic
6
6
  from typing import Optional
7
7
  from typing import TypeVar
8
+ from typing import Union
8
9
 
9
10
  from pydantic import Field
10
11
  from pydantic import ValidationInfo
@@ -26,7 +27,7 @@ from .error import Error
26
27
  from .message import Message
27
28
  from .message import _get_resource_class
28
29
 
29
- T = TypeVar("T", bound=Resource)
30
+ T = TypeVar("T", bound=Resource[Any])
30
31
 
31
32
 
32
33
  class PatchOperation(ComplexAttribute):
@@ -51,7 +52,7 @@ class PatchOperation(ComplexAttribute):
51
52
  describing the target of the operation."""
52
53
 
53
54
  def _validate_mutability(
54
- self, resource_class: type[BaseModel], field_name: str
55
+ self, resource_class: type[Resource[Any]], field_name: str
55
56
  ) -> None:
56
57
  """Validate mutability constraints."""
57
58
  # RFC 7644 Section 3.5.2: "Servers should be tolerant of schema extensions"
@@ -72,7 +73,7 @@ class PatchOperation(ComplexAttribute):
72
73
  raise ValueError(Error.make_mutability_error().detail)
73
74
 
74
75
  def _validate_required_attribute(
75
- self, resource_class: type[BaseModel], field_name: str
76
+ self, resource_class: type[Resource[Any]], field_name: str
76
77
  ) -> None:
77
78
  """Validate required attribute constraints for remove operations."""
78
79
  # RFC 7644 Section 3.5.2.3: Only validate for remove operations
@@ -143,7 +144,7 @@ class PatchOp(Message, Generic[T]):
143
144
  - Using PatchOp without a type parameter raises TypeError
144
145
  """
145
146
 
146
- def __new__(cls, *args, **kwargs):
147
+ def __new__(cls, *args: Any, **kwargs: Any) -> Self:
147
148
  """Create new PatchOp instance with type parameter validation.
148
149
 
149
150
  Only handles the case of direct instantiation without type parameter (PatchOp()).
@@ -162,25 +163,48 @@ class PatchOp(Message, Generic[T]):
162
163
 
163
164
  return super().__new__(cls)
164
165
 
165
- def __class_getitem__(cls, item):
166
+ def __class_getitem__(
167
+ cls, typevar_values: Union[type[Resource[Any]], tuple[type[Resource[Any]], ...]]
168
+ ) -> Any:
166
169
  """Validate type parameter when creating parameterized type.
167
170
 
168
- Ensures the type parameter is a concrete Resource subclass (not Resource itself).
169
- Rejects invalid types (str, int, etc.) and Union types.
171
+ Ensures the type parameter is a concrete Resource subclass (not Resource itself)
172
+ or a TypeVar bound to Resource. Rejects invalid types (str, int, etc.) and Union types.
170
173
  """
174
+ if isinstance(typevar_values, TypeVar):
175
+ # Check if TypeVar is bound to Resource or its subclass
176
+ if typevar_values.__bound__ is not None and (
177
+ typevar_values.__bound__ is Resource
178
+ or (
179
+ isclass(typevar_values.__bound__)
180
+ and issubclass(typevar_values.__bound__, Resource)
181
+ )
182
+ ):
183
+ return super().__class_getitem__(typevar_values)
184
+ else:
185
+ raise TypeError(
186
+ f"PatchOp TypeVar must be bound to Resource or its subclass, got {typevar_values}. "
187
+ "Example: T = TypeVar('T', bound=Resource)"
188
+ )
189
+
171
190
  # Check if type parameter is a concrete Resource subclass (not Resource itself)
172
- if item is Resource:
191
+ if typevar_values is Resource:
173
192
  raise TypeError(
174
193
  "PatchOp requires a concrete Resource subclass, not Resource itself. "
175
194
  "Use PatchOp[User], PatchOp[Group], etc. instead of PatchOp[Resource]."
176
195
  )
177
- if not (isclass(item) and issubclass(item, Resource) and item is not Resource):
196
+
197
+ if not (
198
+ isclass(typevar_values)
199
+ and issubclass(typevar_values, Resource)
200
+ and typevar_values is not Resource
201
+ ):
178
202
  raise TypeError(
179
- f"PatchOp type parameter must be a concrete Resource subclass, got {item}. "
203
+ f"PatchOp type parameter must be a concrete Resource subclass or TypeVar, got {typevar_values}. "
180
204
  "Use PatchOp[User], PatchOp[Group], etc."
181
205
  )
182
206
 
183
- return super().__class_getitem__(item)
207
+ return super().__class_getitem__(typevar_values)
184
208
 
185
209
  schemas: Annotated[list[str], Required.true] = [
186
210
  "urn:ietf:params:scim:api:messages:2.0:PatchOp"
@@ -240,7 +264,9 @@ class PatchOp(Message, Generic[T]):
240
264
 
241
265
  return modified
242
266
 
243
- def _apply_operation(self, resource: Resource, operation: PatchOperation) -> bool:
267
+ def _apply_operation(
268
+ self, resource: Resource[Any], operation: PatchOperation
269
+ ) -> bool:
244
270
  """Apply a single patch operation to a resource.
245
271
 
246
272
  :return: :data:`True` if the resource was modified, else :data:`False`.
@@ -252,7 +278,9 @@ class PatchOp(Message, Generic[T]):
252
278
 
253
279
  raise ValueError(Error.make_invalid_value_error().detail)
254
280
 
255
- def _apply_add_replace(self, resource: Resource, operation: PatchOperation) -> bool:
281
+ def _apply_add_replace(
282
+ self, resource: Resource[Any], operation: PatchOperation
283
+ ) -> bool:
256
284
  """Apply an add or replace operation."""
257
285
  # RFC 7644 Section 3.5.2.1: "If path is specified, add/replace at that path"
258
286
  if operation.path is not None:
@@ -266,7 +294,7 @@ class PatchOp(Message, Generic[T]):
266
294
  # RFC 7644 Section 3.5.2.1: "If no path specified, add/replace at root level"
267
295
  return self._apply_root_attributes(resource, operation.value)
268
296
 
269
- def _apply_remove(self, resource: Resource, operation: PatchOperation) -> bool:
297
+ def _apply_remove(self, resource: Resource[Any], operation: PatchOperation) -> bool:
270
298
  """Apply a remove operation."""
271
299
  # RFC 7644 Section 3.5.2.3: "Path is required for remove operations"
272
300
  if operation.path is None:
@@ -280,7 +308,8 @@ class PatchOp(Message, Generic[T]):
280
308
 
281
309
  return self._remove_value_at_path(resource, operation.path)
282
310
 
283
- def _apply_root_attributes(self, resource: BaseModel, value: Any) -> bool:
311
+ @classmethod
312
+ def _apply_root_attributes(cls, resource: BaseModel, value: Any) -> bool:
284
313
  """Apply attributes to the resource root."""
285
314
  if not isinstance(value, dict):
286
315
  return False
@@ -298,8 +327,9 @@ class PatchOp(Message, Generic[T]):
298
327
 
299
328
  return modified
300
329
 
330
+ @classmethod
301
331
  def _set_value_at_path(
302
- self, resource: Resource, path: str, value: Any, is_add: bool
332
+ cls, resource: Resource[Any], path: str, value: Any, is_add: bool
303
333
  ) -> bool:
304
334
  """Set a value at a specific path."""
305
335
  target, attr_path = _resolve_path_to_target(resource, path)
@@ -309,12 +339,13 @@ class PatchOp(Message, Generic[T]):
309
339
 
310
340
  path_parts = attr_path.split(".")
311
341
  if len(path_parts) == 1:
312
- return self._set_simple_attribute(target, path_parts[0], value, is_add)
342
+ return cls._set_simple_attribute(target, path_parts[0], value, is_add)
313
343
 
314
- return self._set_complex_attribute(target, path_parts, value, is_add)
344
+ return cls._set_complex_attribute(target, path_parts, value, is_add)
315
345
 
346
+ @classmethod
316
347
  def _set_simple_attribute(
317
- self, resource: BaseModel, attr_name: str, value: Any, is_add: bool
348
+ cls, resource: BaseModel, attr_name: str, value: Any, is_add: bool
318
349
  ) -> bool:
319
350
  """Set a value on a simple (non-nested) attribute."""
320
351
  field_name = _find_field_name(type(resource), attr_name)
@@ -322,8 +353,8 @@ class PatchOp(Message, Generic[T]):
322
353
  raise ValueError(Error.make_no_target_error().detail)
323
354
 
324
355
  # RFC 7644 Section 3.5.2.1: "For multi-valued attributes, add operation appends values"
325
- if is_add and self._is_multivalued_field(resource, field_name):
326
- return self._handle_multivalued_add(resource, field_name, value)
356
+ if is_add and cls._is_multivalued_field(resource, field_name):
357
+ return cls._handle_multivalued_add(resource, field_name, value)
327
358
 
328
359
  old_value = getattr(resource, field_name)
329
360
  if old_value == value:
@@ -332,8 +363,9 @@ class PatchOp(Message, Generic[T]):
332
363
  setattr(resource, field_name, value)
333
364
  return True
334
365
 
366
+ @classmethod
335
367
  def _set_complex_attribute(
336
- self, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
368
+ cls, resource: BaseModel, path_parts: list[str], value: Any, is_add: bool
337
369
  ) -> bool:
338
370
  """Set a value on a complex (nested) attribute."""
339
371
  parent_attr = path_parts[0]
@@ -345,38 +377,45 @@ class PatchOp(Message, Generic[T]):
345
377
 
346
378
  parent_obj = getattr(resource, parent_field_name)
347
379
  if parent_obj is None:
348
- parent_obj = self._create_parent_object(resource, parent_field_name)
380
+ parent_obj = cls._create_parent_object(resource, parent_field_name)
349
381
  if parent_obj is None:
350
382
  return False
351
383
 
352
- return self._set_value_at_path(parent_obj, sub_path, value, is_add)
384
+ return cls._set_value_at_path(parent_obj, sub_path, value, is_add)
353
385
 
354
- def _is_multivalued_field(self, resource: BaseModel, field_name: str) -> bool:
386
+ @classmethod
387
+ def _is_multivalued_field(cls, resource: BaseModel, field_name: str) -> bool:
355
388
  """Check if a field is multi-valued."""
356
389
  return hasattr(resource, field_name) and type(resource).get_field_multiplicity(
357
390
  field_name
358
391
  )
359
392
 
393
+ @classmethod
360
394
  def _handle_multivalued_add(
361
- self, resource: BaseModel, field_name: str, value: Any
395
+ cls, resource: BaseModel, field_name: str, value: Any
362
396
  ) -> bool:
363
397
  """Handle adding values to a multi-valued attribute."""
364
398
  current_list = getattr(resource, field_name) or []
365
399
 
366
400
  # RFC 7644 Section 3.5.2.1: "Add operation appends values to multi-valued attributes"
367
401
  if isinstance(value, list):
368
- return self._add_multiple_values(resource, field_name, current_list, value)
402
+ return cls._add_multiple_values(resource, field_name, current_list, value)
369
403
 
370
- return self._add_single_value(resource, field_name, current_list, value)
404
+ return cls._add_single_value(resource, field_name, current_list, value)
371
405
 
406
+ @classmethod
372
407
  def _add_multiple_values(
373
- self, resource: BaseModel, field_name: str, current_list: list, values: list
408
+ cls,
409
+ resource: BaseModel,
410
+ field_name: str,
411
+ current_list: list[Any],
412
+ values: list[Any],
374
413
  ) -> bool:
375
414
  """Add multiple values to a multi-valued attribute."""
376
415
  new_values = []
377
416
  # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
378
417
  for new_val in values:
379
- if not self._value_exists_in_list(current_list, new_val):
418
+ if not cls._value_exists_in_list(current_list, new_val):
380
419
  new_values.append(new_val)
381
420
 
382
421
  if not new_values:
@@ -385,23 +424,26 @@ class PatchOp(Message, Generic[T]):
385
424
  setattr(resource, field_name, current_list + new_values)
386
425
  return True
387
426
 
427
+ @classmethod
388
428
  def _add_single_value(
389
- self, resource: BaseModel, field_name: str, current_list: list, value: Any
429
+ cls, resource: BaseModel, field_name: str, current_list: list[Any], value: Any
390
430
  ) -> bool:
391
431
  """Add a single value to a multi-valued attribute."""
392
432
  # RFC 7644 Section 3.5.2.1: "Do not add duplicate values"
393
- if self._value_exists_in_list(current_list, value):
433
+ if cls._value_exists_in_list(current_list, value):
394
434
  return False
395
435
 
396
436
  current_list.append(value)
397
437
  setattr(resource, field_name, current_list)
398
438
  return True
399
439
 
400
- def _value_exists_in_list(self, current_list: list, new_value: Any) -> bool:
440
+ @classmethod
441
+ def _value_exists_in_list(cls, current_list: list[Any], new_value: Any) -> bool:
401
442
  """Check if a value already exists in a list."""
402
- return any(self._values_match(item, new_value) for item in current_list)
443
+ return any(cls._values_match(item, new_value) for item in current_list)
403
444
 
404
- def _create_parent_object(self, resource: BaseModel, parent_field_name: str) -> Any:
445
+ @classmethod
446
+ def _create_parent_object(cls, resource: BaseModel, parent_field_name: str) -> Any:
405
447
  """Create a parent object if it doesn't exist."""
406
448
  parent_class = type(resource).get_field_root_type(parent_field_name)
407
449
  if not parent_class or not isclass(parent_class):
@@ -411,7 +453,8 @@ class PatchOp(Message, Generic[T]):
411
453
  setattr(resource, parent_field_name, parent_obj)
412
454
  return parent_obj
413
455
 
414
- def _remove_value_at_path(self, resource: Resource, path: str) -> bool:
456
+ @classmethod
457
+ def _remove_value_at_path(cls, resource: Resource[Any], path: str) -> bool:
415
458
  """Remove a value at a specific path."""
416
459
  target, attr_path = _resolve_path_to_target(resource, path)
417
460
 
@@ -434,10 +477,11 @@ class PatchOp(Message, Generic[T]):
434
477
  return True
435
478
 
436
479
  sub_path = ".".join(path_parts)
437
- return self._remove_value_at_path(parent_obj, sub_path)
480
+ return cls._remove_value_at_path(parent_obj, sub_path)
438
481
 
482
+ @classmethod
439
483
  def _remove_specific_value(
440
- self, resource: Resource, path: str, value_to_remove: Any
484
+ cls, resource: Resource[Any], path: str, value_to_remove: Any
441
485
  ) -> bool:
442
486
  """Remove a specific value from a multi-valued attribute."""
443
487
  target, attr_path = _resolve_path_to_target(resource, path)
@@ -458,7 +502,7 @@ class PatchOp(Message, Generic[T]):
458
502
  modified = False
459
503
  # RFC 7644 Section 3.5.2.3: "Remove matching values from multi-valued attributes"
460
504
  for item in current_list:
461
- if not self._values_match(item, value_to_remove):
505
+ if not cls._values_match(item, value_to_remove):
462
506
  new_list.append(item)
463
507
  else:
464
508
  modified = True
@@ -469,10 +513,11 @@ class PatchOp(Message, Generic[T]):
469
513
 
470
514
  return False
471
515
 
472
- def _values_match(self, value1: Any, value2: Any) -> bool:
516
+ @classmethod
517
+ def _values_match(cls, value1: Any, value2: Any) -> bool:
473
518
  """Check if two values match, converting BaseModel to dict for comparison."""
474
519
 
475
- def to_dict(value):
520
+ def to_dict(value: Any) -> dict[str, Any]:
476
521
  return value.model_dump() if isinstance(value, BaseModel) else value
477
522
 
478
523
  return to_dict(value1) == to_dict(value2)
@@ -71,9 +71,7 @@ class SearchRequest(Message):
71
71
  """Validate syntax of sort_by attribute path.
72
72
 
73
73
  :param v: The sort_by attribute path to validate
74
- :type v: Optional[str]
75
74
  :return: The validated sort_by attribute path
76
- :rtype: Optional[str]
77
75
  :raises ValueError: If sort_by attribute path has invalid syntax
78
76
  """
79
77
  if v is None:
@@ -61,9 +61,7 @@ class Reference(UserString, Generic[ReferenceTypes]):
61
61
  """Get reference types from a type annotation.
62
62
 
63
63
  :param type_annotation: Type annotation to extract reference types from
64
- :type type_annotation: Any
65
64
  :return: List of reference type strings
66
- :rtype: list[str]
67
65
  """
68
66
  first_arg = get_args(type_annotation)[0]
69
67
  types = (
@@ -1,4 +1,5 @@
1
1
  from typing import Annotated
2
+ from typing import Any
2
3
  from typing import ClassVar
3
4
  from typing import Literal
4
5
  from typing import Optional
@@ -33,7 +34,7 @@ class GroupMember(MultiValuedComplexAttribute):
33
34
  display: Annotated[Optional[str], Mutability.read_only] = None
34
35
 
35
36
 
36
- class Group(Resource):
37
+ class Group(Resource[Any]):
37
38
  schemas: Annotated[list[str], Required.true] = [
38
39
  "urn:ietf:params:scim:schemas:core:2.0:Group"
39
40
  ]
@@ -148,7 +148,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
148
148
  """A complex attribute containing resource metadata."""
149
149
 
150
150
  @classmethod
151
- def __class_getitem__(cls, item: Any) -> type["Resource"]:
151
+ def __class_getitem__(cls, item: Any) -> type["Resource[Any]"]:
152
152
  """Create a Resource class with extension fields dynamically added."""
153
153
  if hasattr(cls, "__scim_extension_metadata__"):
154
154
  return cls
@@ -241,12 +241,12 @@ class Resource(ScimObject, Generic[AnyExtension]):
241
241
 
242
242
  @staticmethod
243
243
  def get_by_schema(
244
- resource_types: list[type["Resource"]],
244
+ resource_types: list[type["Resource[Any]"]],
245
245
  schema: str,
246
246
  with_extensions: bool = True,
247
- ) -> Optional[Union[type["Resource"], type["Extension"]]]:
247
+ ) -> Optional[Union[type["Resource[Any]"], type["Extension"]]]:
248
248
  """Given a resource type list and a schema, find the matching resource type."""
249
- by_schema: dict[str, Union[type[Resource], type[Extension]]] = {
249
+ by_schema: dict[str, Union[type[Resource[Any]], type[Extension]]] = {
250
250
  resource_type.model_fields["schemas"].default[0].lower(): resource_type
251
251
  for resource_type in (resource_types or [])
252
252
  }
@@ -263,7 +263,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
263
263
 
264
264
  @staticmethod
265
265
  def get_by_payload(
266
- resource_types: list[type["Resource"]],
266
+ resource_types: list[type["Resource[Any]"]],
267
267
  payload: dict[str, Any],
268
268
  **kwargs: Any,
269
269
  ) -> Optional[type]:
@@ -291,7 +291,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
291
291
  return _model_to_schema(cls)
292
292
 
293
293
  @classmethod
294
- def from_schema(cls, schema: "Schema") -> type["Resource"]:
294
+ def from_schema(cls, schema: "Schema") -> type["Resource[Any]"]:
295
295
  """Build a :class:`scim2_models.Resource` subclass from the schema definition."""
296
296
  from .schema import _make_python_model
297
297
 
@@ -372,7 +372,7 @@ class Resource(ScimObject, Generic[AnyExtension]):
372
372
  return super(ScimObject, self).model_dump_json(*args, **dump_kwargs)
373
373
 
374
374
 
375
- AnyResource = TypeVar("AnyResource", bound="Resource")
375
+ AnyResource = TypeVar("AnyResource", bound="Resource[Any]")
376
376
 
377
377
 
378
378
  def _dedicated_attributes(
@@ -1,4 +1,5 @@
1
1
  from typing import Annotated
2
+ from typing import Any
2
3
  from typing import Optional
3
4
 
4
5
  from pydantic import Field
@@ -34,7 +35,7 @@ class SchemaExtension(ComplexAttribute):
34
35
  """
35
36
 
36
37
 
37
- class ResourceType(Resource):
38
+ class ResourceType(Resource[Any]):
38
39
  schemas: Annotated[list[str], Required.true] = [
39
40
  "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
40
41
  ]
@@ -78,7 +79,7 @@ class ResourceType(Resource):
78
79
  """A list of URIs of the resource type's schema extensions."""
79
80
 
80
81
  @classmethod
81
- def from_resource(cls, resource_model: type[Resource]) -> Self:
82
+ def from_resource(cls, resource_model: type[Resource[Any]]) -> Self:
82
83
  """Build a naive ResourceType from a resource model."""
83
84
  schema = resource_model.model_fields["schemas"].default[0]
84
85
  name = schema.split(":")[-1]
@@ -258,7 +258,7 @@ class Attribute(ComplexAttribute):
258
258
  raise KeyError(f"This attribute has no '{name}' sub-attribute")
259
259
 
260
260
 
261
- class Schema(Resource):
261
+ class Schema(Resource[Any]):
262
262
  schemas: Annotated[list[str], Required.true] = [
263
263
  "urn:ietf:params:scim:schemas:core:2.0:Schema"
264
264
  ]
@@ -1,5 +1,6 @@
1
1
  from enum import Enum
2
2
  from typing import Annotated
3
+ from typing import Any
3
4
  from typing import Optional
4
5
 
5
6
  from pydantic import Field
@@ -93,7 +94,7 @@ class AuthenticationScheme(ComplexAttribute):
93
94
  address."""
94
95
 
95
96
 
96
- class ServiceProviderConfig(Resource):
97
+ class ServiceProviderConfig(Resource[Any]):
97
98
  schemas: Annotated[list[str], Required.true] = [
98
99
  "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
99
100
  ]
@@ -1,18 +1,20 @@
1
1
  from typing import TYPE_CHECKING
2
2
  from typing import Any
3
3
  from typing import Optional
4
+ from typing import Union
4
5
 
5
6
  from .base import BaseModel
6
7
  from .utils import _normalize_attribute_name
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from .base import BaseModel
11
+ from .resources.resource import Extension
10
12
  from .resources.resource import Resource
11
13
 
12
14
 
13
15
  def _get_or_create_extension_instance(
14
- model: "Resource", extension_class: type
15
- ) -> "BaseModel":
16
+ model: "Resource[Any]", extension_class: type
17
+ ) -> "Extension":
16
18
  """Get existing extension instance or create a new one."""
17
19
  extension_instance = model[extension_class]
18
20
  if extension_instance is None:
@@ -62,7 +64,7 @@ def _validate_model_attribute(model: type["BaseModel"], attribute_base: str) ->
62
64
 
63
65
 
64
66
  def _validate_attribute_urn(
65
- attribute_name: str, resource: type["Resource"]
67
+ attribute_name: str, resource: type["Resource[Any]"]
66
68
  ) -> Optional[str]:
67
69
  """Validate that an attribute urn is valid or not.
68
70
 
@@ -87,8 +89,8 @@ def _validate_attribute_urn(
87
89
 
88
90
 
89
91
  def _resolve_path_to_target(
90
- resource: "Resource", path: str
91
- ) -> tuple[Optional["BaseModel"], str]:
92
+ resource: "Resource[Any]", path: str
93
+ ) -> tuple[Optional[Union["Resource[Any]", "Extension"]], str]:
92
94
  """Resolve a path to a target and an attribute_path.
93
95
 
94
96
  The target can be the resource itself, or an extension object.