Skip to content

Commit f6e20f0

Browse files
authored
Merge pull request #102 from python-scim/issue-70-normalization
Don't normalize fields typed with `Any`
2 parents f629e5b + 12f4631 commit f6e20f0

File tree

3 files changed

+85
-10
lines changed

3 files changed

+85
-10
lines changed

doc/changelog.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Fixed
1313
^^^^^
1414
- When using ``model_dump``, ignore invalid ``attributes`` and ``excluded_attributes``
1515
as suggested by RFC7644.
16+
- Don't normalize attributes typed with :data:`Any`. :issue:`20`
1617

1718
[0.3.7] - 2025-07-17
1819
--------------------

scim2_models/base.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
from scim2_models.annotations import Required
2424
from scim2_models.annotations import Returned
2525
from scim2_models.context import Context
26+
from scim2_models.utils import UNION_TYPES
27+
from scim2_models.utils import find_field_name
2628
from scim2_models.utils import normalize_attribute_name
2729
from scim2_models.utils import to_camel
2830

29-
from .utils import UNION_TYPES
30-
3131

3232
def contains_attribute_or_subattributes(
3333
attribute_urns: list[str], attribute_urn: str
@@ -152,15 +152,44 @@ def normalize_attribute_names(
152152
transformed in lowercase so any case is handled the same way.
153153
"""
154154

155-
def normalize_value(value: Any) -> Any:
156-
if isinstance(value, dict):
157-
return {
158-
normalize_attribute_name(k): normalize_value(v)
159-
for k, v in value.items()
160-
}
161-
return value
155+
def normalize_dict_keys(
156+
input_dict: dict, model_class: type["BaseModel"]
157+
) -> dict:
158+
"""Normalize dictionary keys, preserving case for Any fields."""
159+
result = {}
160+
161+
for key, val in input_dict.items():
162+
field_name = find_field_name(model_class, key)
163+
field_type = (
164+
model_class.get_field_root_type(field_name) if field_name else None
165+
)
166+
167+
# Don't normalize keys for attributes typed with Any
168+
# This way, agnostic dicts such as PatchOp.operations.value
169+
# are preserved
170+
if field_name and field_type == Any:
171+
result[key] = normalize_value(val)
172+
else:
173+
result[normalize_attribute_name(key)] = normalize_value(
174+
val, field_type
175+
)
176+
177+
return result
178+
179+
def normalize_value(
180+
val: Any, model_class: Optional[type["BaseModel"]] = None
181+
) -> Any:
182+
"""Normalize input value based on model class."""
183+
if not isinstance(val, dict):
184+
return val
185+
186+
# If no model_class, preserve original keys
187+
if not model_class:
188+
return {k: normalize_value(v) for k, v in val.items()}
189+
190+
return normalize_dict_keys(val, model_class)
162191

163-
normalized_value = normalize_value(value)
192+
normalized_value = normalize_value(value, cls)
164193
obj = handler(normalized_value)
165194
assert isinstance(obj, cls)
166195
return obj

tests/test_model_attributes.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from scim2_models.rfc7643.resource import Resource
1414
from scim2_models.rfc7643.user import User
1515
from scim2_models.rfc7644.error import Error
16+
from scim2_models.rfc7644.patch_op import PatchOp
1617
from scim2_models.urn import validate_attribute_urn
1718

1819

@@ -303,3 +304,47 @@ def test_scim_object_model_dump_coverage():
303304
# Test model_dump_json coverage
304305
json_result = error.model_dump_json(scim_ctx=None)
305306
assert isinstance(json_result, str)
307+
308+
309+
def test_patch_op_preserves_case_in_value_fields():
310+
"""Test that PatchOp preserves original case in operation values."""
311+
# Test data from the GitHub issue
312+
patch_data = {
313+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
314+
"Operations": [
315+
{
316+
"op": "replace",
317+
"value": {
318+
"streetAddress": "911 Universal City Plaza",
319+
},
320+
}
321+
],
322+
}
323+
324+
patch_op = PatchOp[User].model_validate(patch_data)
325+
result = patch_op.model_dump()
326+
327+
value = result["Operations"][0]["value"]
328+
assert value["streetAddress"] == "911 Universal City Plaza"
329+
330+
331+
def test_patch_op_preserves_case_in_sub_value_fields():
332+
"""Test that nested objects within Any fields are still normalized according to their schema."""
333+
patch_data = {
334+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
335+
"Operations": [
336+
{
337+
"op": "replace",
338+
"value": {
339+
"name": {"givenName": "John"},
340+
},
341+
}
342+
],
343+
}
344+
345+
patch_op = PatchOp[User].model_validate(patch_data)
346+
result = patch_op.model_dump()
347+
348+
value = result["Operations"][0]["value"]
349+
350+
assert value["name"]["givenName"] == "John"

0 commit comments

Comments
 (0)