From 12f46316ca5a24375b506e71293c993aa5c0037d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 21 Jul 2025 18:14:57 +0200 Subject: [PATCH] fix: don't normalize fields typed with 'Any' So agnostic fields like PatchOp.operation.value are correctly cased --- doc/changelog.rst | 1 + scim2_models/base.py | 49 +++++++++++++++++++++++++++------- tests/test_model_attributes.py | 45 +++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index 79d8b91..f5c6b45 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -13,6 +13,7 @@ Fixed ^^^^^ - When using ``model_dump``, ignore invalid ``attributes`` and ``excluded_attributes`` as suggested by RFC7644. +- Don't normalize attributes typed with :data:`Any`. :issue:`20` [0.3.7] - 2025-07-17 -------------------- diff --git a/scim2_models/base.py b/scim2_models/base.py index 684a5e2..348768c 100644 --- a/scim2_models/base.py +++ b/scim2_models/base.py @@ -23,11 +23,11 @@ from scim2_models.annotations import Required from scim2_models.annotations import Returned from scim2_models.context import Context +from scim2_models.utils import UNION_TYPES +from scim2_models.utils import find_field_name from scim2_models.utils import normalize_attribute_name from scim2_models.utils import to_camel -from .utils import UNION_TYPES - def contains_attribute_or_subattributes( attribute_urns: list[str], attribute_urn: str @@ -152,15 +152,44 @@ def normalize_attribute_names( transformed in lowercase so any case is handled the same way. """ - def normalize_value(value: Any) -> Any: - if isinstance(value, dict): - return { - normalize_attribute_name(k): normalize_value(v) - for k, v in value.items() - } - return value + def normalize_dict_keys( + input_dict: dict, model_class: type["BaseModel"] + ) -> dict: + """Normalize dictionary keys, preserving case for Any fields.""" + result = {} + + for key, val in input_dict.items(): + field_name = find_field_name(model_class, key) + field_type = ( + model_class.get_field_root_type(field_name) if field_name else None + ) + + # Don't normalize keys for attributes typed with Any + # This way, agnostic dicts such as PatchOp.operations.value + # are preserved + if field_name and field_type == Any: + result[key] = normalize_value(val) + else: + result[normalize_attribute_name(key)] = normalize_value( + val, field_type + ) + + return result + + def normalize_value( + val: Any, model_class: Optional[type["BaseModel"]] = None + ) -> Any: + """Normalize input value based on model class.""" + if not isinstance(val, dict): + return val + + # If no model_class, preserve original keys + if not model_class: + return {k: normalize_value(v) for k, v in val.items()} + + return normalize_dict_keys(val, model_class) - normalized_value = normalize_value(value) + normalized_value = normalize_value(value, cls) obj = handler(normalized_value) assert isinstance(obj, cls) return obj diff --git a/tests/test_model_attributes.py b/tests/test_model_attributes.py index 0a77a72..fe7d058 100644 --- a/tests/test_model_attributes.py +++ b/tests/test_model_attributes.py @@ -13,6 +13,7 @@ from scim2_models.rfc7643.resource import Resource from scim2_models.rfc7643.user import User from scim2_models.rfc7644.error import Error +from scim2_models.rfc7644.patch_op import PatchOp from scim2_models.urn import validate_attribute_urn @@ -303,3 +304,47 @@ def test_scim_object_model_dump_coverage(): # Test model_dump_json coverage json_result = error.model_dump_json(scim_ctx=None) assert isinstance(json_result, str) + + +def test_patch_op_preserves_case_in_value_fields(): + """Test that PatchOp preserves original case in operation values.""" + # Test data from the GitHub issue + patch_data = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "replace", + "value": { + "streetAddress": "911 Universal City Plaza", + }, + } + ], + } + + patch_op = PatchOp[User].model_validate(patch_data) + result = patch_op.model_dump() + + value = result["Operations"][0]["value"] + assert value["streetAddress"] == "911 Universal City Plaza" + + +def test_patch_op_preserves_case_in_sub_value_fields(): + """Test that nested objects within Any fields are still normalized according to their schema.""" + patch_data = { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { + "op": "replace", + "value": { + "name": {"givenName": "John"}, + }, + } + ], + } + + patch_op = PatchOp[User].model_validate(patch_data) + result = patch_op.model_dump() + + value = result["Operations"][0]["value"] + + assert value["name"]["givenName"] == "John"