Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------
Expand Down
49 changes: 39 additions & 10 deletions scim2_models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions tests/test_model_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
Loading