diff --git a/doc/changelog.rst b/doc/changelog.rst index 2b71925..f6a9d0a 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -39,6 +39,11 @@ Added sends. Name a policy at the call, or open a ``with`` block on it or on a provider carrying one. Every setting defaults to the strict reading, so nothing changes until one is chosen. See :doc:`how-to/tolerate-a-nonconformant-peer`. :issue:`85` :issue:`108` +- :meth:`~scim2_models.BaseModel.model_dump` and + :meth:`~scim2_models.BaseModel.model_dump_json` take a ``response_parameters``: the + :class:`~scim2_models.ResponseParameters` a client sent, instead of its ``attributes`` and + ``excludedAttributes`` spelled out one by one. A :class:`~scim2_models.SearchRequest` is one, + so a server answering ``POST /.search`` passes the request it received. :issue:`141` - lark is a new dependency. Changed @@ -75,6 +80,14 @@ Removed - ``Path.is_prefix_of`` and ``Path.has_prefix``. They compared the text of two paths, which anything between brackets defeated. +Deprecated +^^^^^^^^^^ +- The ``attributes`` and ``excluded_attributes`` parameters of + :meth:`~scim2_models.BaseModel.model_dump` and + :meth:`~scim2_models.BaseModel.model_dump_json`. Pass a + :class:`~scim2_models.ResponseParameters` as ``response_parameters`` instead; naming both + raises a :exc:`TypeError`. They will be removed in 0.9.0. :issue:`141` + Fixed ^^^^^ - A PATCH operation targeting an attribute of an extension answers for the constraints that diff --git a/doc/how-to/validate-and-serialize.rst b/doc/how-to/validate-and-serialize.rst index cdf0aa3..38f7b8b 100644 --- a/doc/how-to/validate-and-serialize.rst +++ b/doc/how-to/validate-and-serialize.rst @@ -62,17 +62,18 @@ Return only the attributes a client asked for --------------------------------------------- A client narrows a response with the ``attributes`` or ``excludedAttributes`` query parameters of -:rfc:`RFC7644 §3.9 <7644#section-3.9>`. Both take SCIM attribute names, and go straight to the -dump method: +:rfc:`RFC7644 §3.9 <7644#section-3.9>`. Read them into a +:class:`~scim2_models.ResponseParameters` and hand it to the dump method: .. doctest:: + >>> from scim2_models import ResponseParameters >>> user.id = "2819c223-7f76-453a-919d-413861904646" >>> user.display_name = "Babs Jensen" >>> user.title = "Manager" >>> user.model_dump( ... scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ... attributes=["userName"], + ... response_parameters=ResponseParameters(attributes=["userName"]), ... ) # doctest: +NORMALIZE_WHITESPACE {'schemas': ['urn:ietf:params:scim:schemas:core:2.0:User'], 'id': '2819c223-7f76-453a-919d-413861904646', @@ -83,6 +84,10 @@ attribute annotated :attr:`Returned.always ` canno Symmetrically, an attribute annotated :attr:`Returned.never `, such as :attr:`User.password `, never appears whatever a client asks. +A :class:`~scim2_models.SearchRequest` is a :class:`~scim2_models.ResponseParameters`, so a server +answering ``POST /.search`` passes the request it received rather than spelling the two parameters +out. + Replace a stored resource ------------------------- diff --git a/doc/integrations/_examples/django_example.py b/doc/integrations/_examples/django_example.py index 9e0c3c1..3dff743 100644 --- a/doc/integrations/_examples/django_example.py +++ b/doc/integrations/_examples/django_example.py @@ -172,8 +172,7 @@ def get(self, request, app_record): return SCIMJsonResponse( scim_user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) ) @@ -211,8 +210,7 @@ def put(self, request, app_record): return SCIMJsonResponse( response_user.model_dump( scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) ) @@ -240,8 +238,7 @@ def patch(self, request, app_record): return SCIMJsonResponse( scim_user.model_dump( scim_ctx=Context.RESOURCE_PATCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) ) @@ -276,8 +273,7 @@ def users_response(request, req, scim_ctx): return SCIMJsonResponse( response.model_dump( scim_ctx=scim_ctx, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) ) @@ -324,8 +320,7 @@ def post(self, request): return SCIMJsonResponse( response_user.model_dump( scim_ctx=Context.RESOURCE_CREATION_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), status=HTTPStatus.CREATED, ) @@ -394,8 +389,7 @@ def post(self, request): return SCIMJsonResponse( response.model_dump( scim_ctx=Context.SEARCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) ) diff --git a/doc/integrations/_examples/fastapi_example.py b/doc/integrations/_examples/fastapi_example.py index 32ca887..bad6574 100644 --- a/doc/integrations/_examples/fastapi_example.py +++ b/doc/integrations/_examples/fastapi_example.py @@ -144,8 +144,7 @@ async def get_user( return SCIMResponse( scim_user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), ) # -- get-user-end -- @@ -171,8 +170,7 @@ async def patch_user( return SCIMResponse( response_user.model_dump( scim_ctx=Context.RESOURCE_PATCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), ) # -- patch-user-end -- @@ -198,8 +196,7 @@ async def replace_user( return SCIMResponse( response_user.model_dump( scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), ) # -- put-user-end -- @@ -245,8 +242,7 @@ def users_response(request, req, scim_ctx): return SCIMResponse( response.model_dump( scim_ctx=scim_ctx, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), ) @@ -302,8 +298,7 @@ async def search_root( return SCIMResponse( response.model_dump( scim_ctx=Context.SEARCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), ) # -- search-root-end -- @@ -324,8 +319,7 @@ async def create_user( return SCIMResponse( response_user.model_dump( scim_ctx=Context.RESOURCE_CREATION_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), status_code=HTTPStatus.CREATED, ) diff --git a/doc/integrations/_examples/flask_example.py b/doc/integrations/_examples/flask_example.py index a917ed4..51fa44f 100644 --- a/doc/integrations/_examples/flask_example.py +++ b/doc/integrations/_examples/flask_example.py @@ -142,8 +142,7 @@ def get_user(app_record): scim_user = to_scim_user(app_record, resource_location(app_record)) return scim_user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) # -- get-user-end -- @@ -165,8 +164,7 @@ def patch_user(app_record): return scim_user.model_dump( scim_ctx=Context.RESOURCE_PATCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) # -- patch-user-end -- @@ -189,8 +187,7 @@ def replace_user(app_record): response_user = to_scim_user(updated_record, resource_location(updated_record)) return response_user.model_dump( scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) # -- put-user-end -- @@ -231,8 +228,7 @@ def users_response(req, scim_ctx): ) return response.model_dump( scim_ctx=scim_ctx, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) @@ -286,8 +282,7 @@ def search_root(): ) return response.model_dump( scim_ctx=Context.SEARCH_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ) # -- search-root-end -- @@ -308,8 +303,7 @@ def create_user(): return ( response_user.model_dump( scim_ctx=Context.RESOURCE_CREATION_RESPONSE, - attributes=req.attributes, - excluded_attributes=req.excluded_attributes, + response_parameters=req, ), HTTPStatus.CREATED, ) diff --git a/doc/overview.rst b/doc/overview.rst index f6fa1fb..141ccb5 100644 --- a/doc/overview.rst +++ b/doc/overview.rst @@ -59,7 +59,7 @@ attributes: .. doctest:: - >>> from scim2_models import Context, User + >>> from scim2_models import Context, ResponseParameters, User >>> payload = { ... "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], ... "id": "client-supplied", @@ -78,7 +78,7 @@ projection requested by a client: >>> user.display_name = "Babs Jensen" >>> response = user.model_dump( ... scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - ... excluded_attributes=["displayName"], + ... response_parameters=ResponseParameters(excluded_attributes=["displayName"]), ... ) >>> response["id"] '2819c223-7f76-453a-919d-413861904646' diff --git a/scim2_models/base.py b/scim2_models/base.py index 8c3d95e..bccf61c 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import Mapping from inspect import isclass from types import MappingProxyType @@ -40,6 +41,7 @@ from scim2_models.utils import _to_camel if TYPE_CHECKING: + from scim2_models.messages.response_parameters import ResponseParameters from scim2_models.path import Path @@ -846,10 +848,47 @@ def _prepare_model_dump( return kwargs + @staticmethod + def _attribute_selection( + response_parameters: "ResponseParameters[Any] | None", + attributes: list["str | Path[Any]"] | None, + excluded_attributes: list["str | Path[Any]"] | None, + ) -> tuple[list["str | Path[Any]"] | None, list["str | Path[Any]"] | None]: + """Read the attribute selection of a dump, from either spelling.""" + if response_parameters is None: + if attributes is not None or excluded_attributes is not None: + warnings.warn( + "The 'attributes' and 'excluded_attributes' parameters are " + "deprecated, pass a ResponseParameters as 'response_parameters' " + "instead. Will be removed in 0.9.0.", + DeprecationWarning, + stacklevel=3, + ) + return attributes, excluded_attributes + + if attributes is not None or excluded_attributes is not None: + raise TypeError( + "Cannot pass both 'response_parameters' and " + "'attributes' or 'excluded_attributes'" + ) + # les listes de ResponseParameters sont invariantes, on les recopie élargies + selected: list[str | Path[Any]] | None = ( + list(response_parameters.attributes) + if response_parameters.attributes is not None + else None + ) + excluded: list[str | Path[Any]] | None = ( + list(response_parameters.excluded_attributes) + if response_parameters.excluded_attributes is not None + else None + ) + return selected, excluded + def model_dump( self, *args: Any, scim_ctx: Context | None = Context.DEFAULT, + response_parameters: "ResponseParameters[Any] | None" = None, attributes: list["str | Path[Any]"] | None = None, excluded_attributes: list["str | Path[Any]"] | None = None, scim_policy: ScimPolicy | None = None, @@ -860,15 +899,31 @@ def model_dump( :param scim_ctx: If a SCIM context is passed, some default values of Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM messages. Pass :data:`None` to get the default Pydantic behavior. + :param response_parameters: The + :class:`~scim2_models.ResponseParameters` a client sent, whose + ``attributes`` and ``excludedAttributes`` select what the dump + carries. A :class:`~scim2_models.SearchRequest` is one, so a server + may pass the request it received. :param attributes: A multi-valued list of strings indicating the names of resource attributes to return in the response, overriding the set of attributes that would be returned by default. Invalid values are ignored. + + .. deprecated:: 0.8.0 + Pass a :class:`~scim2_models.ResponseParameters` as + *response_parameters* instead. Will be removed in 0.9.0. :param excluded_attributes: A multi-valued list of strings indicating the names of resource attributes to be removed from the default set of attributes to return. Invalid values are ignored. + + .. deprecated:: 0.8.0 + Pass a :class:`~scim2_models.ResponseParameters` as + *response_parameters* instead. Will be removed in 0.9.0. :param scim_policy: The :class:`~scim2_models.ScimPolicy` the serialization runs under. Defaults to the strict reading of the specification. """ + attributes, excluded_attributes = self._attribute_selection( + response_parameters, attributes, excluded_attributes + ) dump_kwargs = self._prepare_model_dump( scim_ctx, attributes=attributes, @@ -884,6 +939,7 @@ def model_dump_json( self, *args: Any, scim_ctx: Context | None = Context.DEFAULT, + response_parameters: "ResponseParameters[Any] | None" = None, attributes: list["str | Path[Any]"] | None = None, excluded_attributes: list["str | Path[Any]"] | None = None, scim_policy: ScimPolicy | None = None, @@ -894,15 +950,31 @@ def model_dump_json( :param scim_ctx: If a SCIM context is passed, some default values of Pydantic :code:`BaseModel.model_dump` are tuned to generate valid SCIM messages. Pass :data:`None` to get the default Pydantic behavior. + :param response_parameters: The + :class:`~scim2_models.ResponseParameters` a client sent, whose + ``attributes`` and ``excludedAttributes`` select what the dump + carries. A :class:`~scim2_models.SearchRequest` is one, so a server + may pass the request it received. :param attributes: A multi-valued list of strings indicating the names of resource attributes to return in the response, overriding the set of attributes that would be returned by default. Invalid values are ignored. + + .. deprecated:: 0.8.0 + Pass a :class:`~scim2_models.ResponseParameters` as + *response_parameters* instead. Will be removed in 0.9.0. :param excluded_attributes: A multi-valued list of strings indicating the names of resource attributes to be removed from the default set of attributes to return. Invalid values are ignored. + + .. deprecated:: 0.8.0 + Pass a :class:`~scim2_models.ResponseParameters` as + *response_parameters* instead. Will be removed in 0.9.0. :param scim_policy: The :class:`~scim2_models.ScimPolicy` the serialization runs under. Defaults to the strict reading of the specification. """ + attributes, excluded_attributes = self._attribute_selection( + response_parameters, attributes, excluded_attributes + ) dump_kwargs = self._prepare_model_dump( scim_ctx, attributes=attributes, diff --git a/tests/test_dynamic_resources.py b/tests/test_dynamic_resources.py index a18d15e..e65ddd9 100644 --- a/tests/test_dynamic_resources.py +++ b/tests/test_dynamic_resources.py @@ -12,6 +12,7 @@ from scim2_models.annotations import Uniqueness from scim2_models.attributes import ComplexAttribute from scim2_models.context import Context +from scim2_models.messages.response_parameters import ResponseParameters from scim2_models.path import Path from scim2_models.path import ScimFilter from scim2_models.reference import URI @@ -2844,7 +2845,9 @@ def test_models_built_from_a_schema_know_their_attribute_urns(): assert obj.get_attribute_urn("attr") == "urn:example:2.0:Single:attr" assert obj.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["urn:example:2.0:Single:attr"], + response_parameters=ResponseParameters( + attributes=["urn:example:2.0:Single:attr"] + ), ) == {"schemas": ["urn:example:2.0:Single"], "attr": "value"} @@ -2867,7 +2870,7 @@ def test_extensions_built_from_a_schema_know_their_attribute_urns(): assert obj[ExtModel].get_attribute_urn("attr") == "urn:example:2.0:Ext:attr" assert obj.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["urn:example:2.0:Ext:attr"], + response_parameters=ResponseParameters(attributes=["urn:example:2.0:Ext:attr"]), ) == { "schemas": ["urn:example:2.0:Single", "urn:example:2.0:Ext"], "urn:example:2.0:Ext": {"attr": "value"}, diff --git a/tests/test_list_response.py b/tests/test_list_response.py index c70459b..035a1c3 100644 --- a/tests/test_list_response.py +++ b/tests/test_list_response.py @@ -7,6 +7,7 @@ from scim2_models import ListResponse from scim2_models import Resource from scim2_models import ResourceType +from scim2_models import ResponseParameters from scim2_models import ServiceProviderConfig from scim2_models import User from scim2_models.urn import URN @@ -282,7 +283,8 @@ def test_attributes_inclusion(): ], ) payload = response.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), ) assert payload == { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], @@ -306,7 +308,8 @@ def test_excluded_attributes(): ], ) payload = response.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, excluded_attributes=["displayName"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(excluded_attributes=["displayName"]), ) assert "displayName" not in payload["Resources"][0] assert payload["Resources"][0]["userName"] == "user-name" @@ -322,7 +325,9 @@ def test_attributes_inclusion_with_full_urn(): ) payload = response.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["urn:ietf:params:scim:schemas:core:2.0:User:userName"], + response_parameters=ResponseParameters( + attributes=["urn:ietf:params:scim:schemas:core:2.0:User:userName"] + ), ) resource = payload["Resources"][0] assert resource["userName"] == "user-name" @@ -339,7 +344,11 @@ def test_excluded_attributes_with_full_urn(): ) payload = response.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes=["urn:ietf:params:scim:schemas:core:2.0:User:displayName"], + response_parameters=ResponseParameters( + excluded_attributes=[ + "urn:ietf:params:scim:schemas:core:2.0:User:displayName" + ] + ), ) resource = payload["Resources"][0] assert "displayName" not in resource @@ -359,7 +368,8 @@ def test_attributes_with_union_type(load_sample): } response = ListResponse[User | Group].model_validate(payload) dumped = response.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), ) user_resource = dumped["Resources"][0] assert "userName" in user_resource @@ -370,7 +380,8 @@ def test_attributes_with_empty_resources(): """ListResponse serialization handles empty resources when attributes are set.""" response = ListResponse[User](total_results=0, resources=[]) payload = response.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), ) assert payload["Resources"] == [] diff --git a/tests/test_model_attributes.py b/tests/test_model_attributes.py index 6bdb71a..2b27a74 100644 --- a/tests/test_model_attributes.py +++ b/tests/test_model_attributes.py @@ -6,6 +6,7 @@ from pydantic import Field from scim2_models import URN +from scim2_models import ResponseParameters from scim2_models.annotations import CaseExact from scim2_models.annotations import Returned from scim2_models.attributes import ComplexAttribute @@ -157,7 +158,8 @@ def test_attribute_inclusion_case_sensitivity(): """ user = User.model_validate({"userName": "foobar"}) assert user.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["userName"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), ) == { "userName": "foobar", "schemas": [ @@ -166,7 +168,8 @@ def test_attribute_inclusion_case_sensitivity(): } assert user.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["username"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["username"]), ) == { "userName": "foobar", "schemas": [ @@ -175,7 +178,8 @@ def test_attribute_inclusion_case_sensitivity(): } assert user.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes=["USERNAME"] + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["USERNAME"]), ) == { "userName": "foobar", "schemas": [ @@ -185,7 +189,9 @@ def test_attribute_inclusion_case_sensitivity(): assert user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["urn:ietf:params:scim:schemas:core:2.0:User:userName"], + response_parameters=ResponseParameters( + attributes=["urn:ietf:params:scim:schemas:core:2.0:User:userName"] + ), ) == { "userName": "foobar", "schemas": [ @@ -195,7 +201,9 @@ def test_attribute_inclusion_case_sensitivity(): assert user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["urn:ietf:params:scim:schemas:core:2.0:User:username"], + response_parameters=ResponseParameters( + attributes=["urn:ietf:params:scim:schemas:core:2.0:User:username"] + ), ) == { "userName": "foobar", "schemas": [ @@ -204,7 +212,9 @@ def test_attribute_inclusion_case_sensitivity(): } assert user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["URN:IETF:PARAMS:SCIM:SCHEMAS:CORE:2.0:USER:USERNAME"], + response_parameters=ResponseParameters( + attributes=["URN:IETF:PARAMS:SCIM:SCHEMAS:CORE:2.0:USER:USERNAME"] + ), ) == { "userName": "foobar", "schemas": [ @@ -238,10 +248,12 @@ def test_attribute_inclusion_schema_extensions(): assert ( user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=[ - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", - ], + response_parameters=ResponseParameters( + attributes=[ + "urn:ietf:params:scim:schemas:core:2.0:User:userName", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber", + ] + ), ) == expected ) @@ -249,10 +261,12 @@ def test_attribute_inclusion_schema_extensions(): assert ( user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=[ - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - "URN:IETF:PARAMS:SCIM:SCHEMAS:EXTENSION:ENTERPRISE:2.0:USER:EMPLOYEENUMBER", - ], + response_parameters=ResponseParameters( + attributes=[ + "urn:ietf:params:scim:schemas:core:2.0:User:userName", + "URN:IETF:PARAMS:SCIM:SCHEMAS:EXTENSION:ENTERPRISE:2.0:USER:EMPLOYEENUMBER", + ] + ), ) == expected ) @@ -370,7 +384,7 @@ def test_complex_attribute_inclusion_includes_sub_attributes(): ) result = user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["name"], + response_parameters=ResponseParameters(attributes=["name"]), ) assert result["name"] == {"givenName": "Barbara", "familyName": "Jensen"} @@ -387,7 +401,7 @@ def test_multivalued_complex_attribute_inclusion_includes_sub_attributes(): ) result = group.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=["members"], + response_parameters=ResponseParameters(attributes=["members"]), ) assert result["members"] == [ {"value": "user-1", "type": "User"}, @@ -431,9 +445,11 @@ def test_extension_excluded_by_full_urn(): ) result = user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes=[ - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" - ], + response_parameters=ResponseParameters( + excluded_attributes=[ + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber" + ] + ), ) ext = result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] assert "employeeNumber" not in ext diff --git a/tests/test_model_serialization.py b/tests/test_model_serialization.py index c3020da..c1d420d 100644 --- a/tests/test_model_serialization.py +++ b/tests/test_model_serialization.py @@ -3,6 +3,9 @@ import pytest from scim2_models import URN +from scim2_models import ResponseParameters +from scim2_models import SearchRequest +from scim2_models import User from scim2_models.annotations import Mutability from scim2_models.annotations import Returned from scim2_models.attributes import ComplexAttribute @@ -418,7 +421,8 @@ def test_invalid_attributes(): # Invalid attributes should be silently ignored: no match means only # Returned.always attributes remain. result = resource.model_dump( - scim_ctx=Context.RESOURCE_QUERY_RESPONSE, attributes={"invalidAttribute"} + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes={"invalidAttribute"}), ) assert result == { "schemas": ["urn:org:example:SupRetResource"], @@ -428,7 +432,9 @@ def test_invalid_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes={"urn:org:example:SupRetResource:invalidAttribute"}, + response_parameters=ResponseParameters( + attributes={"urn:org:example:SupRetResource:invalidAttribute"} + ), ) assert result == { "schemas": ["urn:org:example:SupRetResource"], @@ -438,7 +444,9 @@ def test_invalid_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes={"urn:invalid:schema:invalidAttribute"}, + response_parameters=ResponseParameters( + attributes={"urn:invalid:schema:invalidAttribute"} + ), ) assert result == { "schemas": ["urn:org:example:SupRetResource"], @@ -454,7 +462,9 @@ def test_invalid_excluded_attributes(): # Invalid excluded_attributes should be ignored, not raise errors result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes={"invalidAttribute"}, + response_parameters=ResponseParameters( + excluded_attributes={"invalidAttribute"} + ), ) # Should return default response (nothing excluded) assert result == { @@ -466,7 +476,9 @@ def test_invalid_excluded_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes={"urn:org:example:SupRetResource:invalidAttribute"}, + response_parameters=ResponseParameters( + excluded_attributes={"urn:org:example:SupRetResource:invalidAttribute"} + ), ) assert result == { "schemas": ["urn:org:example:SupRetResource"], @@ -477,7 +489,9 @@ def test_invalid_excluded_attributes(): result = resource.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - excluded_attributes={"urn:invalid:schema:invalidAttribute"}, + response_parameters=ResponseParameters( + excluded_attributes={"urn:invalid:schema:invalidAttribute"} + ), ) assert result == { "schemas": ["urn:org:example:SupRetResource"], @@ -518,20 +532,27 @@ def test_dump_response(context, ret_resource): }, } - assert ret_resource.model_dump(scim_ctx=context, attributes={"alwaysReturned"}) == { + assert ret_resource.model_dump( + scim_ctx=context, + response_parameters=ResponseParameters(attributes={"alwaysReturned"}), + ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", } - assert ret_resource.model_dump(scim_ctx=context, attributes={"neverReturned"}) == { + assert ret_resource.model_dump( + scim_ctx=context, + response_parameters=ResponseParameters(attributes={"neverReturned"}), + ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", } assert ret_resource.model_dump( - scim_ctx=context, attributes={"defaultReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(attributes={"defaultReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -539,7 +560,9 @@ def test_dump_response(context, ret_resource): "defaultReturned": "x", } - assert ret_resource.model_dump(scim_ctx=context, attributes={"sub"}) == { + assert ret_resource.model_dump( + scim_ctx=context, response_parameters=ResponseParameters(attributes={"sub"}) + ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", "alwaysReturned": "x", @@ -550,7 +573,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, attributes={"sub.defaultReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(attributes={"sub.defaultReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -562,7 +586,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, attributes={"requestReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(attributes={"requestReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -572,7 +597,9 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, - attributes={"defaultReturned", "requestReturned"}, + response_parameters=ResponseParameters( + attributes={"defaultReturned", "requestReturned"} + ), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -582,7 +609,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, excluded_attributes={"alwaysReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(excluded_attributes={"alwaysReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -595,7 +623,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, excluded_attributes={"neverReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(excluded_attributes={"neverReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -608,7 +637,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, excluded_attributes={"defaultReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(excluded_attributes={"defaultReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -620,7 +650,8 @@ def test_dump_response(context, ret_resource): } assert ret_resource.model_dump( - scim_ctx=context, excluded_attributes={"requestReturned"} + scim_ctx=context, + response_parameters=ResponseParameters(excluded_attributes={"requestReturned"}), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -634,7 +665,9 @@ def test_dump_response(context, ret_resource): assert ret_resource.model_dump( scim_ctx=context, - excluded_attributes={"defaultReturned", "requestReturned"}, + response_parameters=ResponseParameters( + excluded_attributes={"defaultReturned", "requestReturned"} + ), ) == { "schemas": ["urn:org:example:SupRetResource"], "id": "id", @@ -644,3 +677,67 @@ def test_dump_response(context, ret_resource): "defaultReturned": "x", }, } + + +def test_response_parameters_select_what_the_dump_carries(): + """A ResponseParameters selects the attributes, as the two keywords used to.""" + user = User(id="id", user_name="bjensen", display_name="Babs") + parameters = ResponseParameters(attributes=["userName"]) + + assert user.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, response_parameters=parameters + ) == { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], + "id": "id", + "userName": "bjensen", + } + + +def test_a_search_request_selects_what_the_dump_carries(): + """A SearchRequest is a ResponseParameters, so a server passes the request it received.""" + user = User(id="id", user_name="bjensen", display_name="Babs") + request = SearchRequest[User](excluded_attributes=["displayName"]) + + dumped = user.model_dump( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, response_parameters=request + ) + assert "displayName" not in dumped + assert dumped["userName"] == "bjensen" + + +def test_response_parameters_reach_the_json_dump(): + """model_dump_json takes the selection the same way model_dump does.""" + user = User(id="id", user_name="bjensen", display_name="Babs") + dumped = user.model_dump_json( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), + ) + assert "displayName" not in dumped + assert '"userName":"bjensen"' in dumped + + +@pytest.mark.parametrize("method", ["model_dump", "model_dump_json"]) +@pytest.mark.parametrize("keyword", ["attributes", "excluded_attributes"]) +def test_the_two_spellings_of_the_selection_cannot_be_mixed(method, keyword): + """Naming both a ResponseParameters and a bare list leaves the selection ambiguous.""" + user = User(id="id", user_name="bjensen") + with pytest.raises(TypeError, match="Cannot pass both"): + getattr(user, method)( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, + response_parameters=ResponseParameters(attributes=["userName"]), + **{keyword: ["displayName"]}, + ) + + +@pytest.mark.parametrize("method", ["model_dump", "model_dump_json"]) +@pytest.mark.parametrize( + ("keyword", "carried"), [("attributes", True), ("excluded_attributes", False)] +) +def test_the_bare_selection_keywords_are_deprecated(method, keyword, carried): + """The two keywords still select, and announce their replacement.""" + user = User(id="id", user_name="bjensen", display_name="Babs") + with pytest.warns(DeprecationWarning, match="response_parameters"): + dumped = getattr(user, method)( + scim_ctx=Context.RESOURCE_QUERY_RESPONSE, **{keyword: ["userName"]} + ) + assert ("bjensen" in str(dumped)) is carried diff --git a/tests/test_resource_extension.py b/tests/test_resource_extension.py index 4b7d552..a11fddd 100644 --- a/tests/test_resource_extension.py +++ b/tests/test_resource_extension.py @@ -17,6 +17,7 @@ from scim2_models import Meta from scim2_models import Required from scim2_models import Resource +from scim2_models import ResponseParameters from scim2_models import Schema from scim2_models import User @@ -297,9 +298,11 @@ def test_extensions_schemas(): ) assert user.model_dump( scim_ctx=Context.RESOURCE_QUERY_RESPONSE, - attributes=[ - "urn:ietf:params:scim:schemas:core:2.0:User:userName", - ], + response_parameters=ResponseParameters( + attributes=[ + "urn:ietf:params:scim:schemas:core:2.0:User:userName", + ] + ), ) == { "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User",