scim2-models 0.2.12__tar.gz → 0.3.1__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.
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.pre-commit-config.yaml +3 -3
- {scim2_models-0.2.12 → scim2_models-0.3.1}/PKG-INFO +3 -2
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/changelog.rst +26 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/conf.py +1 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/tutorial.rst +7 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/pyproject.toml +2 -1
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/base.py +73 -19
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/schema.py +12 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/list_response.py +13 -1
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/search_request.py +5 -5
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_list_response.py +23 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_model_serialization.py +1 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_model_validation.py +89 -2
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_patch_op.py +0 -3
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_schema.py +12 -2
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_search_request.py +5 -8
- scim2_models-0.3.1/uv.lock +1197 -0
- scim2_models-0.2.12/uv.lock +0 -1188
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.github/FUNDING.yml +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.github/workflows/release.yml +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.github/workflows/tests.yaml +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.gitignore +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/.readthedocs.yml +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/LICENSE +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/README.md +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/conftest.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/__init__.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/contributing.rst +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/index.rst +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/doc/reference.rst +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.1-user-minimal.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.2-user-full.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.3-enterprise_user.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.4-group.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.5-service_provider_configuration.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.6-resource_type-group.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.6-resource_type-user.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.1-schema-enterprise_user.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.1-schema-group.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.1-schema-user.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.2-schema-resource_type.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.2-schema-schema.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7643-8.7.2-schema-service_provider_configuration.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.12-error-bad_request.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.12-error-not_found.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.14-user-post_request.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.14-user-post_response.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.3-user-post_request.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.3-user-post_response.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.4.1-user-known-resource.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.4.2-list_response-partial_attributes.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.4.3-list_response-post_query.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.4.3-search_request.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.1-user-put_request.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.1-user-put_response.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.1-patch_op-add_emails.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.1-patch_op-add_members.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.2-patch_op-remove_all_members.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.2-patch_op-remove_and_add_one_member.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.2-patch_op-remove_multi_complex_value.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.2-patch_op-remove_one_member.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.3-patch_op-replace_all_email_values.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.3-patch_op-replace_all_members.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.3-patch_op-replace_street_address.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.5.2.3-patch_op-replace_user_work_address.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.6-error-not_found.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.1-bulk_request-circular_conflict.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.1-list_response-circular_reference.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.2-bulk_request-enterprise_user.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.2-bulk_request-temporary_identifier.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.2-bulk_response-temporary_identifier.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.3-bulk_request-multiple_operations.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.3-bulk_response-error_invalid_syntax.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.3-bulk_response-multiple_errors.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.3-bulk_response-multiple_operations.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.3-error-invalid_syntax.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.7.4-error-payload_too_large.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-3.9-user-partial_response.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/samples/rfc7644-4-list_response-resource_types.json +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/__init__.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/constants.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/py.typed +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/__init__.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/enterprise_user.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/group.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/resource.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/resource_type.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/service_provider_config.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7643/user.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/__init__.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/bulk.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/error.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/message.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/rfc7644/patch_op.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/scim2_models/utils.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/__init__.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/conftest.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_dynamic_resources.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_dynamic_schemas.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_enterprise_user.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_errors.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_group.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_model_attributes.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_models.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_resource_extension.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_resource_type.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_service_provider_configuration.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/tests/test_user.py +0 -0
- {scim2_models-0.2.12 → scim2_models-0.3.1}/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.
|
|
4
|
+
rev: 'v0.9.10'
|
|
5
5
|
hooks:
|
|
6
6
|
- id: ruff
|
|
7
7
|
args: [--fix, --exit-non-zero-on-fix]
|
|
@@ -16,11 +16,11 @@ 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.
|
|
19
|
+
rev: v1.15.0
|
|
20
20
|
hooks:
|
|
21
21
|
- id: mypy
|
|
22
22
|
- repo: https://github.com/codespell-project/codespell
|
|
23
|
-
rev: v2.
|
|
23
|
+
rev: v2.4.1
|
|
24
24
|
hooks:
|
|
25
25
|
- id: codespell
|
|
26
26
|
additional_dependencies:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: scim2-models
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -208,6 +208,7 @@ License: Apache License
|
|
|
208
208
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
209
209
|
See the License for the specific language governing permissions and
|
|
210
210
|
limitations under the License.
|
|
211
|
+
License-File: LICENSE
|
|
211
212
|
Keywords: provisioning,pydantic,rfc7643,rfc7644,scim,scim2
|
|
212
213
|
Classifier: Development Status :: 3 - Alpha
|
|
213
214
|
Classifier: Environment :: Web Environment
|
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
[0.3.1] - 2025-03-07
|
|
5
|
+
--------------------
|
|
6
|
+
|
|
7
|
+
Fixed
|
|
8
|
+
^^^^^
|
|
9
|
+
- Fix :attr:`~SearchRequest.start_index` and :attr:`~SearchRequest.count` limits. :issue:`84`
|
|
10
|
+
- :attr:`~ListResponse.total_resuls` is required.
|
|
11
|
+
|
|
12
|
+
[0.3.0] - 2024-12-11
|
|
13
|
+
--------------------
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
^^^^^
|
|
17
|
+
- :meth:`Attribute.get_attribute <scim2_models.Attribute.get_attribute>` can be called with brackets.
|
|
18
|
+
|
|
19
|
+
Changed
|
|
20
|
+
^^^^^^^
|
|
21
|
+
- Add a :paramref:`~scim2_models.BaseModel.model_validate.original`
|
|
22
|
+
parameter to :meth:`~scim2_models.BaseModel.model_validate`
|
|
23
|
+
mandatory for :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST`.
|
|
24
|
+
This *original* value is used to look if :attr:`~scim2_models.Mutability.immutable`
|
|
25
|
+
parameters have mutated.
|
|
26
|
+
:issue:`86`
|
|
27
|
+
|
|
4
28
|
[0.2.12] - 2024-12-09
|
|
5
29
|
---------------------
|
|
30
|
+
|
|
6
31
|
Added
|
|
7
32
|
^^^^^
|
|
8
33
|
- Implement :meth:`Attribute.get_attribute <scim2_models.Attribute.get_attribute>`.
|
|
9
34
|
|
|
10
35
|
[0.2.11] - 2024-12-08
|
|
11
36
|
---------------------
|
|
37
|
+
|
|
12
38
|
Added
|
|
13
39
|
^^^^^
|
|
14
40
|
- Implement :meth:`Schema.get_attribute <scim2_models.Schema.get_attribute>`.
|
|
@@ -102,6 +102,13 @@ fields with unexpected values will raise :class:`~pydantic.ValidationError`:
|
|
|
102
102
|
... except pydantic.ValidationError:
|
|
103
103
|
... obj = Error(...)
|
|
104
104
|
|
|
105
|
+
.. note::
|
|
106
|
+
|
|
107
|
+
With the :attr:`~scim2_models.Context.RESOURCE_REPLACEMENT_REQUEST` context,
|
|
108
|
+
:meth:`~scim2_models.BaseModel.model_validate` takes an additional
|
|
109
|
+
:paramref:`~scim2_models.BaseModel.model_validate.original` argument that is used to compare
|
|
110
|
+
:attr:`~scim2_models.Mutability.immutable` attributes, and raise an exception when they have mutated.
|
|
111
|
+
|
|
105
112
|
Attributes inclusions and exclusions
|
|
106
113
|
====================================
|
|
107
114
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scim2-models"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
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"}
|
|
@@ -48,6 +48,7 @@ doc = [
|
|
|
48
48
|
"autodoc-pydantic>=2.2.0",
|
|
49
49
|
"myst-parser>=3.0.1",
|
|
50
50
|
"shibuya>=2024.5.15",
|
|
51
|
+
"sphinx-paramlinks>=0.6.0",
|
|
51
52
|
"sphinx>=7.3.7",
|
|
52
53
|
"sphinx-issues >= 5.0.0",
|
|
53
54
|
"sphinx-togglebutton>=0.3.2",
|
|
@@ -233,9 +233,9 @@ class Context(Enum):
|
|
|
233
233
|
Should be used for clients building a payload for a resource replacement request,
|
|
234
234
|
and servers validating resource replacement request payloads.
|
|
235
235
|
|
|
236
|
-
- When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only
|
|
236
|
+
- When used for serialization, it will not dump attributes annotated with :attr:`~scim2_models.Mutability.read_only`.
|
|
237
237
|
- When used for validation, it will ignore attributes annotated with :attr:`scim2_models.Mutability.read_only` and raise a :class:`~pydantic.ValidationError`:
|
|
238
|
-
- when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable
|
|
238
|
+
- when finding attributes annotated with :attr:`~scim2_models.Mutability.immutable` different than :paramref:`~scim2_models.BaseModel.model_validate.original`:
|
|
239
239
|
- when attributes annotated with :attr:`Required.true <scim2_models.Required.true>` are missing on null.
|
|
240
240
|
"""
|
|
241
241
|
|
|
@@ -492,12 +492,6 @@ class BaseModel(PydanticBaseModel):
|
|
|
492
492
|
):
|
|
493
493
|
raise exc
|
|
494
494
|
|
|
495
|
-
if (
|
|
496
|
-
context == Context.RESOURCE_REPLACEMENT_REQUEST
|
|
497
|
-
and mutability == Mutability.immutable
|
|
498
|
-
):
|
|
499
|
-
raise exc
|
|
500
|
-
|
|
501
495
|
if (
|
|
502
496
|
context
|
|
503
497
|
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
@@ -604,8 +598,55 @@ class BaseModel(PydanticBaseModel):
|
|
|
604
598
|
|
|
605
599
|
return value
|
|
606
600
|
|
|
601
|
+
@model_validator(mode="wrap")
|
|
602
|
+
@classmethod
|
|
603
|
+
def check_replacement_request_mutability(
|
|
604
|
+
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
|
|
605
|
+
) -> Self:
|
|
606
|
+
"""Check if 'immutable' attributes have been mutated in replacement requests."""
|
|
607
|
+
from scim2_models.rfc7643.resource import Resource
|
|
608
|
+
|
|
609
|
+
value = handler(value)
|
|
610
|
+
|
|
611
|
+
context = info.context.get("scim") if info.context else None
|
|
612
|
+
original = info.context.get("original") if info.context else None
|
|
613
|
+
if (
|
|
614
|
+
context == Context.RESOURCE_REPLACEMENT_REQUEST
|
|
615
|
+
and issubclass(cls, Resource)
|
|
616
|
+
and original is not None
|
|
617
|
+
):
|
|
618
|
+
cls.check_mutability_issues(original, value)
|
|
619
|
+
return value
|
|
620
|
+
|
|
621
|
+
@classmethod
|
|
622
|
+
def check_mutability_issues(cls, original: "BaseModel", replacement: "BaseModel"):
|
|
623
|
+
"""Compare two instances, and check for differences of values on the fields marked as immutable."""
|
|
624
|
+
model = replacement.__class__
|
|
625
|
+
for field_name in model.model_fields:
|
|
626
|
+
mutability = model.get_field_annotation(field_name, Mutability)
|
|
627
|
+
if mutability == Mutability.immutable and getattr(
|
|
628
|
+
original, field_name
|
|
629
|
+
) != getattr(replacement, field_name):
|
|
630
|
+
raise PydanticCustomError(
|
|
631
|
+
"mutability_error",
|
|
632
|
+
"Field '{field_name}' is immutable but the request value is different than the original value.",
|
|
633
|
+
{"field_name": field_name},
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
attr_type = model.get_field_root_type(field_name)
|
|
637
|
+
if is_complex_attribute(attr_type) and not model.get_field_multiplicity(
|
|
638
|
+
field_name
|
|
639
|
+
):
|
|
640
|
+
original_val = getattr(original, field_name)
|
|
641
|
+
replacement_value = getattr(replacement, field_name)
|
|
642
|
+
if original_val is not None and replacement_value is not None:
|
|
643
|
+
cls.check_mutability_issues(original_val, replacement_value)
|
|
644
|
+
|
|
607
645
|
def mark_with_schema(self):
|
|
608
|
-
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_schema' attribute.
|
|
646
|
+
"""Navigate through attributes and sub-attributes of type ComplexAttribute, and mark them with a '_schema' attribute.
|
|
647
|
+
|
|
648
|
+
'_schema' will later be used by 'get_attribute_urn'.
|
|
649
|
+
"""
|
|
609
650
|
from scim2_models.rfc7643.resource import Resource
|
|
610
651
|
|
|
611
652
|
for field_name in self.model_fields:
|
|
@@ -653,7 +694,8 @@ class BaseModel(PydanticBaseModel):
|
|
|
653
694
|
scim_ctx = info.context.get("scim") if info.context else None
|
|
654
695
|
|
|
655
696
|
if (
|
|
656
|
-
scim_ctx
|
|
697
|
+
scim_ctx
|
|
698
|
+
in (Context.RESOURCE_CREATION_REQUEST, Context.RESOURCE_REPLACEMENT_REQUEST)
|
|
657
699
|
and mutability == Mutability.read_only
|
|
658
700
|
):
|
|
659
701
|
return None
|
|
@@ -668,12 +710,6 @@ class BaseModel(PydanticBaseModel):
|
|
|
668
710
|
):
|
|
669
711
|
return None
|
|
670
712
|
|
|
671
|
-
if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and mutability in (
|
|
672
|
-
Mutability.immutable,
|
|
673
|
-
Mutability.read_only,
|
|
674
|
-
):
|
|
675
|
-
return None
|
|
676
|
-
|
|
677
713
|
return value
|
|
678
714
|
|
|
679
715
|
def scim_response_serializer(self, value: Any, info: SerializationInfo) -> Any:
|
|
@@ -719,10 +755,28 @@ class BaseModel(PydanticBaseModel):
|
|
|
719
755
|
|
|
720
756
|
@classmethod
|
|
721
757
|
def model_validate(
|
|
722
|
-
cls,
|
|
758
|
+
cls,
|
|
759
|
+
*args,
|
|
760
|
+
scim_ctx: Optional[Context] = Context.DEFAULT,
|
|
761
|
+
original: Optional["BaseModel"] = None,
|
|
762
|
+
**kwargs,
|
|
723
763
|
) -> Self:
|
|
724
|
-
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
|
|
725
|
-
|
|
764
|
+
"""Validate SCIM payloads and generate model representation by using Pydantic :code:`BaseModel.model_validate`.
|
|
765
|
+
|
|
766
|
+
:param scim_ctx: The SCIM :class:`~scim2_models.Context` in which the validation happens.
|
|
767
|
+
:param original: If this parameter is set during :attr:`~Context.RESOURCE_REPLACEMENT_REQUEST`,
|
|
768
|
+
:attr:`~scim2_models.Mutability.immutable` parameters will be compared against the *original* model value.
|
|
769
|
+
An exception is raised if values are different.
|
|
770
|
+
"""
|
|
771
|
+
context = kwargs.setdefault("context", {})
|
|
772
|
+
context.setdefault("scim", scim_ctx)
|
|
773
|
+
context.setdefault("original", original)
|
|
774
|
+
|
|
775
|
+
if scim_ctx == Context.RESOURCE_REPLACEMENT_REQUEST and original is None:
|
|
776
|
+
raise ValueError(
|
|
777
|
+
"Resource queries replacement validation must compare to an original resource"
|
|
778
|
+
)
|
|
779
|
+
|
|
726
780
|
return super().model_validate(*args, **kwargs)
|
|
727
781
|
|
|
728
782
|
def _prepare_model_dump(
|
|
@@ -245,6 +245,12 @@ class Attribute(ComplexAttribute):
|
|
|
245
245
|
return sub_attribute
|
|
246
246
|
return None
|
|
247
247
|
|
|
248
|
+
def __getitem__(self, name):
|
|
249
|
+
"""Find an attribute by its name."""
|
|
250
|
+
if attribute := self.get_attribute(name):
|
|
251
|
+
return attribute
|
|
252
|
+
raise KeyError(f"This attribute has no '{name}' sub-attribute")
|
|
253
|
+
|
|
248
254
|
|
|
249
255
|
class Schema(Resource):
|
|
250
256
|
schemas: Annotated[list[str], Required.true] = [
|
|
@@ -280,3 +286,9 @@ class Schema(Resource):
|
|
|
280
286
|
if attribute.name == attribute_name:
|
|
281
287
|
return attribute
|
|
282
288
|
return None
|
|
289
|
+
|
|
290
|
+
def __getitem__(self, name):
|
|
291
|
+
"""Find an attribute by its name."""
|
|
292
|
+
if attribute := self.get_attribute(name):
|
|
293
|
+
return attribute
|
|
294
|
+
raise KeyError(f"This schema has no '{name}' attribute")
|
|
@@ -104,7 +104,13 @@ class ListResponse(Message, Generic[AnyResource], metaclass=ListResponseMetaclas
|
|
|
104
104
|
def check_results_number(
|
|
105
105
|
cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo
|
|
106
106
|
) -> Self:
|
|
107
|
-
"""
|
|
107
|
+
"""Validate result numbers.
|
|
108
|
+
|
|
109
|
+
:rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>` indicates that:
|
|
110
|
+
|
|
111
|
+
- 'totalResults' is required
|
|
112
|
+
- 'resources' must be set if 'totalResults' is non-zero.
|
|
113
|
+
"""
|
|
108
114
|
obj = handler(value)
|
|
109
115
|
|
|
110
116
|
if (
|
|
@@ -114,6 +120,12 @@ class ListResponse(Message, Generic[AnyResource], metaclass=ListResponseMetaclas
|
|
|
114
120
|
):
|
|
115
121
|
return obj
|
|
116
122
|
|
|
123
|
+
if obj.total_results is None:
|
|
124
|
+
raise PydanticCustomError(
|
|
125
|
+
"required_error",
|
|
126
|
+
"Field 'total_results' is required but value is missing or null",
|
|
127
|
+
)
|
|
128
|
+
|
|
117
129
|
if obj.total_results > 0 and not obj.resources:
|
|
118
130
|
raise PydanticCustomError(
|
|
119
131
|
"no_resource_error",
|
|
@@ -49,11 +49,11 @@ class SearchRequest(Message):
|
|
|
49
49
|
@field_validator("start_index")
|
|
50
50
|
@classmethod
|
|
51
51
|
def start_index_floor(cls, value: int) -> int:
|
|
52
|
-
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than
|
|
52
|
+
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, start_index values less than 1 are interpreted as 1.
|
|
53
53
|
|
|
54
54
|
A value less than 1 SHALL be interpreted as 1.
|
|
55
55
|
"""
|
|
56
|
-
return None if value is None else max(
|
|
56
|
+
return None if value is None else max(1, value)
|
|
57
57
|
|
|
58
58
|
count: Optional[int] = None
|
|
59
59
|
"""An integer indicating the desired maximum number of query results per
|
|
@@ -62,11 +62,11 @@ class SearchRequest(Message):
|
|
|
62
62
|
@field_validator("count")
|
|
63
63
|
@classmethod
|
|
64
64
|
def count_floor(cls, value: int) -> int:
|
|
65
|
-
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than
|
|
65
|
+
"""According to :rfc:`RFC7644 §3.4.2 <7644#section-3.4.2.4>, count values less than 0 are interpreted as 0.
|
|
66
66
|
|
|
67
|
-
A value
|
|
67
|
+
A negative value SHALL be interpreted as 0.
|
|
68
68
|
"""
|
|
69
|
-
return None if value is None else max(
|
|
69
|
+
return None if value is None else max(0, value)
|
|
70
70
|
|
|
71
71
|
@model_validator(mode="after")
|
|
72
72
|
def attributes_validator(self):
|
|
@@ -217,3 +217,26 @@ def test_list_response_schema_ordering():
|
|
|
217
217
|
],
|
|
218
218
|
}
|
|
219
219
|
ListResponse[Union[User[EnterpriseUser], Group]].model_validate(payload)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_total_results_required():
|
|
223
|
+
"""ListResponse.total_results is required."""
|
|
224
|
+
payload = {
|
|
225
|
+
"Resources": [
|
|
226
|
+
{
|
|
227
|
+
"schemas": [
|
|
228
|
+
"urn:ietf:params:scim:schemas:core:2.0:User",
|
|
229
|
+
],
|
|
230
|
+
"userName": "bjensen@example.com",
|
|
231
|
+
"id": "foobar",
|
|
232
|
+
}
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
with pytest.raises(
|
|
237
|
+
ValidationError,
|
|
238
|
+
match="Field 'total_results' is required but value is missing or null",
|
|
239
|
+
):
|
|
240
|
+
ListResponse[User].model_validate(
|
|
241
|
+
payload, scim_ctx=Context.RESOURCE_QUERY_RESPONSE
|
|
242
|
+
)
|
|
@@ -4,6 +4,7 @@ from typing import Optional
|
|
|
4
4
|
import pytest
|
|
5
5
|
from pydantic import ValidationError
|
|
6
6
|
|
|
7
|
+
from scim2_models.base import ComplexAttribute
|
|
7
8
|
from scim2_models.base import Context
|
|
8
9
|
from scim2_models.base import Mutability
|
|
9
10
|
from scim2_models.base import Required
|
|
@@ -144,31 +145,113 @@ def test_validate_replacement_request_mutability():
|
|
|
144
145
|
"""Test query validation for resource model replacement requests.
|
|
145
146
|
|
|
146
147
|
Attributes marked as:
|
|
147
|
-
- Mutability.immutable raise a ValidationError
|
|
148
|
+
- Mutability.immutable raise a ValidationError if different than the 'original' item.
|
|
148
149
|
- Mutability.read_only are ignored
|
|
149
150
|
"""
|
|
151
|
+
with pytest.raises(
|
|
152
|
+
ValueError,
|
|
153
|
+
match="Resource queries replacement validation must compare to an original resource",
|
|
154
|
+
):
|
|
155
|
+
MutResource.model_validate(
|
|
156
|
+
{
|
|
157
|
+
"readOnly": "x",
|
|
158
|
+
"readWrite": "x",
|
|
159
|
+
"writeOnly": "x",
|
|
160
|
+
},
|
|
161
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
original = MutResource(read_only="y", read_write="y", write_only="y", immutable="y")
|
|
150
165
|
assert MutResource.model_validate(
|
|
151
166
|
{
|
|
152
167
|
"readOnly": "x",
|
|
153
168
|
"readWrite": "x",
|
|
154
169
|
"writeOnly": "x",
|
|
170
|
+
"immutable": "y",
|
|
155
171
|
},
|
|
156
172
|
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
173
|
+
original=original,
|
|
157
174
|
) == MutResource(
|
|
158
175
|
schemas=["org:example:MutResource"],
|
|
159
176
|
readWrite="x",
|
|
160
177
|
writeOnly="x",
|
|
178
|
+
immutable="y",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
MutResource.model_validate(
|
|
182
|
+
{
|
|
183
|
+
"immutable": "y",
|
|
184
|
+
},
|
|
185
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
186
|
+
original=original,
|
|
161
187
|
)
|
|
162
188
|
|
|
163
189
|
with pytest.raises(
|
|
164
190
|
ValidationError,
|
|
165
|
-
match="Field 'immutable'
|
|
191
|
+
match="Field 'immutable' is immutable but the request value is different than the original value.",
|
|
166
192
|
):
|
|
167
193
|
MutResource.model_validate(
|
|
168
194
|
{
|
|
169
195
|
"immutable": "x",
|
|
170
196
|
},
|
|
171
197
|
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
198
|
+
original=original,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_validate_replacement_request_mutability_sub_attributes():
|
|
203
|
+
"""Test query validation for resource model replacement requests.
|
|
204
|
+
|
|
205
|
+
Sub-attributes marked as:
|
|
206
|
+
- Mutability.immutable raise a ValidationError if different than the 'original' item.
|
|
207
|
+
- Mutability.read_only are ignored
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
class Sub(ComplexAttribute):
|
|
211
|
+
immutable: Annotated[Optional[str], Mutability.immutable] = None
|
|
212
|
+
|
|
213
|
+
class Super(Resource):
|
|
214
|
+
schemas: Annotated[list[str], Required.true] = ["org:example:Super"]
|
|
215
|
+
sub: Optional[Sub] = None
|
|
216
|
+
|
|
217
|
+
original = Super(sub=Sub(immutable="y"))
|
|
218
|
+
assert Super.model_validate(
|
|
219
|
+
{
|
|
220
|
+
"sub": {
|
|
221
|
+
"immutable": "y",
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
225
|
+
original=original,
|
|
226
|
+
) == Super(
|
|
227
|
+
schemas=["org:example:Super"],
|
|
228
|
+
sub=Sub(
|
|
229
|
+
immutable="y",
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
Super.model_validate(
|
|
234
|
+
{
|
|
235
|
+
"sub": {
|
|
236
|
+
"immutable": "y",
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
240
|
+
original=original,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
with pytest.raises(
|
|
244
|
+
ValidationError,
|
|
245
|
+
match="Field 'immutable' is immutable but the request value is different than the original value.",
|
|
246
|
+
):
|
|
247
|
+
Super.model_validate(
|
|
248
|
+
{
|
|
249
|
+
"sub": {
|
|
250
|
+
"immutable": "x",
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
254
|
+
original=original,
|
|
172
255
|
)
|
|
173
256
|
|
|
174
257
|
|
|
@@ -378,12 +461,14 @@ def test_validate_creation_and_replacement_request_necessity(context):
|
|
|
378
461
|
Attributes marked as:
|
|
379
462
|
- Required.true and missing raise a ValidationError
|
|
380
463
|
"""
|
|
464
|
+
original = MutResource(read_only="y", read_write="y", write_only="y", immutable="y")
|
|
381
465
|
assert ReqResource.model_validate(
|
|
382
466
|
{
|
|
383
467
|
"required": "x",
|
|
384
468
|
"optional": "x",
|
|
385
469
|
},
|
|
386
470
|
scim_ctx=context,
|
|
471
|
+
original=original,
|
|
387
472
|
) == ReqResource(
|
|
388
473
|
schemas=["org:example:ReqResource"],
|
|
389
474
|
required="x",
|
|
@@ -395,6 +480,7 @@ def test_validate_creation_and_replacement_request_necessity(context):
|
|
|
395
480
|
"required": "x",
|
|
396
481
|
},
|
|
397
482
|
scim_ctx=context,
|
|
483
|
+
original=original,
|
|
398
484
|
) == ReqResource(
|
|
399
485
|
schemas=["org:example:ReqResource"],
|
|
400
486
|
required="x",
|
|
@@ -408,6 +494,7 @@ def test_validate_creation_and_replacement_request_necessity(context):
|
|
|
408
494
|
{
|
|
409
495
|
"optional": "x",
|
|
410
496
|
},
|
|
497
|
+
original=original,
|
|
411
498
|
scim_ctx=context,
|
|
412
499
|
)
|
|
413
500
|
|
|
@@ -3,7 +3,6 @@ from pydantic import ValidationError
|
|
|
3
3
|
|
|
4
4
|
from scim2_models import PatchOp
|
|
5
5
|
from scim2_models import PatchOperation
|
|
6
|
-
from scim2_models.base import Context
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
def test_validate_patchop_case_insensitivith():
|
|
@@ -16,7 +15,6 @@ def test_validate_patchop_case_insensitivith():
|
|
|
16
15
|
{"op": "ReMove", "path": "userName", "value": "Rivard"},
|
|
17
16
|
],
|
|
18
17
|
},
|
|
19
|
-
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
20
18
|
) == PatchOp(
|
|
21
19
|
operations=[
|
|
22
20
|
PatchOperation(
|
|
@@ -36,5 +34,4 @@ def test_validate_patchop_case_insensitivith():
|
|
|
36
34
|
{
|
|
37
35
|
"operations": [{"op": 42, "path": "userName", "value": "Rivard"}],
|
|
38
36
|
},
|
|
39
|
-
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
|
|
40
37
|
)
|
|
@@ -92,13 +92,18 @@ def test_get_schema_attribute(load_sample):
|
|
|
92
92
|
payload = load_sample("rfc7643-8.7.1-schema-user.json")
|
|
93
93
|
schema = Schema.model_validate(payload)
|
|
94
94
|
assert schema.get_attribute("invalid") is None
|
|
95
|
+
with pytest.raises(KeyError):
|
|
96
|
+
schema["invalid"]
|
|
95
97
|
|
|
96
98
|
assert schema.attributes[0].name == "userName"
|
|
97
99
|
assert schema.attributes[0].mutability == Mutability.read_write
|
|
98
|
-
schema.get_attribute("userName").mutability = Mutability.read_only
|
|
99
100
|
|
|
101
|
+
schema.get_attribute("userName").mutability = Mutability.read_only
|
|
100
102
|
assert schema.attributes[0].mutability == Mutability.read_only
|
|
101
103
|
|
|
104
|
+
schema["userName"].mutability = Mutability.read_write
|
|
105
|
+
assert schema.attributes[0].mutability == Mutability.read_write
|
|
106
|
+
|
|
102
107
|
|
|
103
108
|
def test_get_attribute_attribute(load_sample):
|
|
104
109
|
"""Test the Schema.get_attribute method."""
|
|
@@ -107,9 +112,14 @@ def test_get_attribute_attribute(load_sample):
|
|
|
107
112
|
attribute = schema.get_attribute("members")
|
|
108
113
|
|
|
109
114
|
assert attribute.get_attribute("invalid") is None
|
|
115
|
+
with pytest.raises(KeyError):
|
|
116
|
+
attribute["invalid"]
|
|
110
117
|
|
|
111
118
|
assert attribute.sub_attributes[0].name == "value"
|
|
112
119
|
assert attribute.sub_attributes[0].mutability == Mutability.immutable
|
|
113
|
-
attribute.get_attribute("value").mutability = Mutability.read_only
|
|
114
120
|
|
|
121
|
+
attribute.get_attribute("value").mutability = Mutability.read_only
|
|
115
122
|
assert attribute.sub_attributes[0].mutability == Mutability.read_only
|
|
123
|
+
|
|
124
|
+
attribute["value"].mutability = Mutability.read_write
|
|
125
|
+
assert attribute.sub_attributes[0].mutability == Mutability.read_write
|
|
@@ -29,13 +29,13 @@ def test_start_index_floor():
|
|
|
29
29
|
|
|
30
30
|
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
|
31
31
|
|
|
32
|
-
A
|
|
32
|
+
A value less than 1 SHALL be interpreted as 1.
|
|
33
33
|
"""
|
|
34
34
|
sr = SearchRequest(start_index=100)
|
|
35
35
|
assert sr.start_index == 100
|
|
36
36
|
|
|
37
|
-
sr = SearchRequest(start_index
|
|
38
|
-
assert sr.start_index ==
|
|
37
|
+
sr = SearchRequest(start_index=0)
|
|
38
|
+
assert sr.start_index == 1
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def test_count_floor():
|
|
@@ -43,16 +43,13 @@ def test_count_floor():
|
|
|
43
43
|
|
|
44
44
|
https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.4
|
|
45
45
|
|
|
46
|
-
A value
|
|
46
|
+
A negative value SHALL be interpreted as 0.
|
|
47
47
|
"""
|
|
48
48
|
sr = SearchRequest(count=100)
|
|
49
49
|
assert sr.count == 100
|
|
50
50
|
|
|
51
|
-
sr = SearchRequest(count=0)
|
|
52
|
-
assert sr.count == 1
|
|
53
|
-
|
|
54
51
|
sr = SearchRequest(count=-1)
|
|
55
|
-
assert sr.count ==
|
|
52
|
+
assert sr.count == 0
|
|
56
53
|
|
|
57
54
|
|
|
58
55
|
def test_attributes_or_excluded_attributes():
|