From 57b83f63b41d0130953de2166e24d9d5c0540a64 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 17 Nov 2025 16:06:58 -0500 Subject: [PATCH 01/11] add tests and fix backcompat functions --- .../azure-core/azure/core/serialization.py | 84 +++- .../modeltypes/_utils/model_base.py | 19 + .../azure-core/tests/test_serialization.py | 472 ++++++++++++++++++ 3 files changed, 562 insertions(+), 13 deletions(-) diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index df6db58319e0..ea312929739c 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -331,6 +331,17 @@ def _as_attribute_dict_value(v: Any, *, exclude_readonly: bool = False) -> Any: return {dk: _as_attribute_dict_value(dv, exclude_readonly=exclude_readonly) for dk, dv in v.items()} return as_attribute_dict(v, exclude_readonly=exclude_readonly) if is_generated_model(v) else v +def _get_backcompat_attr_to_rest_field(obj: Any) -> Dict[str, Any]: + """Get the backcompat attribute to rest field mapping for a generated TypeSpec model. + + :param any obj: The object to get the mapping from. + :return: The backcompat attribute to rest field mapping. + :rtype: Dict[str, Any] + """ + try: + return obj._backcompat_attr_to_rest_field # pylint: disable=protected-access + except AttributeError: + return obj._attr_to_rest_field # pylint: disable=protected-access def _get_flattened_attribute(obj: Any) -> Optional[str]: """Get the name of the flattened attribute in a generated TypeSpec model if one exists. @@ -348,11 +359,18 @@ def _get_flattened_attribute(obj: Any) -> Optional[str]: if flattened_items is None: return None - for k, v in obj._attr_to_rest_field.items(): # pylint: disable=protected-access + flattened_items_set = set(flattened_items) + for k, v in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access try: - if set(v._class_type._attr_to_rest_field.keys()).intersection( # pylint: disable=protected-access - set(flattened_items) - ): + # Check if this property contains a nested model + if not hasattr(v, '_class_type'): + continue + + # Get backcompat names from the nested model + nested_backcompat_names = set(_get_backcompat_attr_to_rest_field(v._class_type).keys()) # pylint: disable=protected-access + + # If any flattened items match the backcompat names in the nested model, this is our flattened attribute + if flattened_items_set.intersection(nested_backcompat_names): return k except AttributeError: # if the attribute does not have _class_type, it is not a typespec generated model @@ -375,9 +393,15 @@ def attribute_list(obj: Any) -> List[str]: return list(obj._attribute_map.keys()) # pylint: disable=protected-access flattened_attribute = _get_flattened_attribute(obj) retval: List[str] = [] - for attr_name, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access + for attr_name, rest_field in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access if flattened_attribute == attr_name: - retval.extend(attribute_list(rest_field._class_type)) # pylint: disable=protected-access + # For flattened attributes, return the flattened item names from __flattened_items + try: + flattened_items = getattr(obj, next(a for a in dir(obj) if "__flattened_items" in a), None) + if flattened_items: + retval.extend(flattened_items) + except StopIteration: + pass else: retval.append(attr_name) return retval @@ -410,28 +434,62 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, # create a reverse mapping from rest field name to attribute name rest_to_attr = {} flattened_attribute = _get_flattened_attribute(obj) - for attr_name, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access + flattened_items = None + flattened_actual_attr = None # Store the actual attribute name for the flattened attribute + if flattened_attribute: + try: + flattened_items = getattr(obj, next(a for a in dir(obj) if "__flattened_items" in a), None) + # Find the actual attribute name that corresponds to the flattened attribute + # flattened_attribute could be either an actual attr name or a backcompat name + # We need to find the actual attr name that appears in obj.items() + for actual_attr_name, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access + # Get the backcompat name for this attribute + original_tsp_name = getattr(rest_field, "_original_tsp_name", None) + backcompat_name = original_tsp_name if original_tsp_name else actual_attr_name + + if backcompat_name == flattened_attribute: + flattened_actual_attr = actual_attr_name + break + except StopIteration: + pass + + for attr_name, rest_field in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access if exclude_readonly and _is_readonly(rest_field): # if we're excluding readonly properties, we need to track them readonly_props.add(rest_field._rest_name) # pylint: disable=protected-access if flattened_attribute == attr_name: - for fk, fv in rest_field._class_type._attr_to_rest_field.items(): # pylint: disable=protected-access - rest_to_attr[fv._rest_name] = fk # pylint: disable=protected-access + # For flattened attributes, map flattened item names directly to their nested field names + nested_backcompat_map = _get_backcompat_attr_to_rest_field(rest_field._class_type) # pylint: disable=protected-access + for flattened_name in flattened_items or []: + if flattened_name in nested_backcompat_map: + nested_field = nested_backcompat_map[flattened_name] + rest_to_attr[nested_field._rest_name] = flattened_name # pylint: disable=protected-access else: rest_to_attr[rest_field._rest_name] = attr_name # pylint: disable=protected-access + for k, v in obj.items(): if exclude_readonly and k in readonly_props: # pyright: ignore continue - if k == flattened_attribute: - for fk, fv in v.items(): - result[rest_to_attr.get(fk, fk)] = _as_attribute_dict_value(fv, exclude_readonly=exclude_readonly) + if k == flattened_actual_attr: + # For flattened attributes, extract values from nested model using backcompat names + if hasattr(v, 'items'): + for fk, fv in v.items(): + mapped_name = rest_to_attr.get(fk, fk) + if mapped_name in (flattened_items or []): + # Check if this flattened item should be excluded due to readonly + nested_backcompat_map = _get_backcompat_attr_to_rest_field(getattr(obj._attr_to_rest_field[flattened_actual_attr], '_class_type')) # pylint: disable=protected-access + if mapped_name in nested_backcompat_map: + nested_field = nested_backcompat_map[mapped_name] + if exclude_readonly and _is_readonly(nested_field): + continue + result[mapped_name] = _as_attribute_dict_value(fv, exclude_readonly=exclude_readonly) else: is_multipart_file_input = False try: is_multipart_file_input = next( # pylint: disable=protected-access rf - for rf in obj._attr_to_rest_field.values() # pylint: disable=protected-access + for rf in _get_backcompat_attr_to_rest_field(obj).values() # pylint: disable=protected-access if rf._rest_name == k # pylint: disable=protected-access )._is_multipart_file_input except StopIteration: diff --git a/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py b/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py index e46c56097a8c..9172543a2791 100644 --- a/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py +++ b/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py @@ -654,6 +654,10 @@ def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> Self: if not rf._rest_name_input: rf._rest_name_input = attr cls._attr_to_rest_field: typing.Dict[str, _RestField] = dict(attr_to_rest_field.items()) + cls._backcompat_attr_to_rest_field: typing.Dict[str, _RestField] = { + Model._get_backcompat_attribute_name(cls._attr_to_rest_field, attr): rf for attr, rf in cls + ._attr_to_rest_field.items() + } cls._calculated.add(f"{cls.__module__}.{cls.__qualname__}") return super().__new__(cls) @@ -663,6 +667,17 @@ def __init_subclass__(cls, discriminator: typing.Optional[str] = None) -> None: if hasattr(base, "__mapping__"): base.__mapping__[discriminator or cls.__name__] = cls # type: ignore + @classmethod + def _get_backcompat_attribute_name(cls, _attr_to_rest_field: typing.Dict[str, "_RestField"], attr_name: str) -> str: + rest_field = _attr_to_rest_field.get(attr_name) # pylint: disable=protected-access + if rest_field is None: + return attr_name + original_tsp_name = getattr(rest_field, "_original_tsp_name", None) # pylint: disable=protected-access + if original_tsp_name: + return original_tsp_name + return attr_name + + @classmethod def _get_discriminator(cls, exist_discriminators) -> typing.Optional["_RestField"]: for v in cls.__dict__.values(): @@ -998,6 +1013,7 @@ def __init__( format: typing.Optional[str] = None, is_multipart_file_input: bool = False, xml: typing.Optional[typing.Dict[str, typing.Any]] = None, + original_tsp_name: typing.Optional[str] = None, ): self._type = type self._rest_name_input = name @@ -1009,6 +1025,7 @@ def __init__( self._format = format self._is_multipart_file_input = is_multipart_file_input self._xml = xml if xml is not None else {} + self._original_tsp_name = original_tsp_name @property def _class_type(self) -> typing.Any: @@ -1060,6 +1077,7 @@ def rest_field( format: typing.Optional[str] = None, is_multipart_file_input: bool = False, xml: typing.Optional[typing.Dict[str, typing.Any]] = None, + original_tsp_name: typing.Optional[str] = None, ) -> typing.Any: return _RestField( name=name, @@ -1069,6 +1087,7 @@ def rest_field( format=format, is_multipart_file_input=is_multipart_file_input, xml=xml, + original_tsp_name=original_tsp_name, ) diff --git a/sdk/core/azure-core/tests/test_serialization.py b/sdk/core/azure-core/tests/test_serialization.py index 094715c9d30f..3fb7d1a4c80e 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -1644,3 +1644,475 @@ def deserializer_b(cls, data: Dict[str, Any]) -> ModelB: deserialized_a = _deserialize(ModelA, json_dict_a) assert isinstance(deserialized_a, ModelA) assert deserialized_a.type == "A2" + + +class TestBackcompatPropertyMatrix: + """ + Systematic test matrix for DPG model property backcompat scenarios. + + Tests all combinations of 5 key dimensions: + 1. wireName: same/different from attr_name + 2. attr_name: normal/padded (reserved word) + 3. original_tsp_name: None/present (TSP name before padding) + 4. visibility: readonly/readwrite (affects exclude_readonly) + 5. structure: regular/nested/flattened models + + COMPLETE TEST MATRIX: + ┌───────┬─────────────┬──────────────┬─────────────────┬────────────┬──────────────┬─────────────────────────────┐ + │ Test │ Wire Name │ Attr Name │ Original TSP │ Visibility │ Structure │ Expected Behavior │ + ├───────┼─────────────┼──────────────┼─────────────────┼────────────┼──────────────┼─────────────────────────────┤ + │ 1a │ same │ normal │ None │ readwrite │ regular │ attr_name │ + │ 1b │ same │ normal │ None │ readonly │ regular │ attr_name (exclude test) │ + │ 2a │ different │ normal │ None │ readwrite │ regular │ attr_name │ + │ 2b │ different │ normal │ None │ readonly │ regular │ attr_name (exclude test) │ + │ 3a │ same │ padded │ present │ readwrite │ regular │ original_tsp_name │ + │ 3b │ same │ padded │ present │ readonly │ regular │ original_tsp_name (exclude) │ + │ 4a │ different │ padded │ present │ readwrite │ regular │ original_tsp_name │ + │ 4b │ different │ padded │ present │ readonly │ regular │ original_tsp_name (exclude) │ + │ 5a │ various │ mixed │ mixed │ mixed │ nested │ recursive backcompat │ + │ 6a │ same │ padded │ present │ readwrite │ flat-contain │ flattened + backcompat │ + │ 6b │ various │ mixed │ mixed │ mixed │ flat-props │ flattened props backcompat │ + │ 6c │ various │ mixed │ mixed │ readonly │ flat-mixed │ flattened + exclude │ + └───────┴─────────────┴──────────────┴─────────────────┴────────────┴──────────────┴─────────────────────────────┘ + """ + + # ========== DIMENSION 1-4 COMBINATIONS: REGULAR STRUCTURE ========== + + def test_1a_same_wire_normal_attr_no_original_readwrite_regular(self): + """Wire=attr, normal attr, no original, readwrite, regular model""" + + class RegularModel(HybridModel): + field_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) + + model = RegularModel(field_name="value") + + # Should use attr_name (same as wire name) + assert attribute_list(model) == ["field_name"] + assert as_attribute_dict(model) == {"field_name": "value"} + assert as_attribute_dict(model, exclude_readonly=True) == {"field_name": "value"} + + def test_1b_same_wire_normal_attr_no_original_readonly_regular(self): + """Wire=attr, normal attr, no original, readonly, regular model""" + + class ReadonlyModel(HybridModel): + field_name: str = rest_field(visibility=["read"]) + + model = ReadonlyModel(field_name="value") + + # Should use attr_name, but excluded when exclude_readonly=True + assert attribute_list(model) == ["field_name"] + assert as_attribute_dict(model) == {"field_name": "value"} + assert as_attribute_dict(model, exclude_readonly=True) == {} + + def test_2a_different_wire_normal_attr_no_original_readwrite_regular(self): + """Wire≠attr, normal attr, no original, readwrite, regular model""" + + class DifferentWireModel(HybridModel): + client_field: str = rest_field(name="wireField", visibility=["read", "create", "update", "delete", "query"]) + + model = DifferentWireModel(client_field="value") + + # Should use attr_name (wire name is different) + assert attribute_list(model) == ["client_field"] + assert as_attribute_dict(model) == {"client_field": "value"} + # Verify wire representation uses different name + assert dict(model) == {"wireField": "value"} + + def test_2b_different_wire_normal_attr_no_original_readonly_regular(self): + """Wire≠attr, normal attr, no original, readonly, regular model""" + + class ReadonlyDifferentWireModel(HybridModel): + client_field: str = rest_field(name="wireField", visibility=["read"]) + + model = ReadonlyDifferentWireModel(client_field="value") + + # Should use attr_name, excluded when exclude_readonly=True + assert attribute_list(model) == ["client_field"] + assert as_attribute_dict(model) == {"client_field": "value"} + assert as_attribute_dict(model, exclude_readonly=True) == {} + + def test_3a_same_wire_padded_attr_with_original_readwrite_regular(self): + """Wire=original, padded attr, original present, readwrite, regular model""" + + class PaddedModel(HybridModel): + keys_property: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Set original TSP name for backcompat + self._attr_to_rest_field['keys_property']._original_tsp_name = "keys" + # Create backcompat mapping + self._backcompat_attr_to_rest_field = { + "keys": self._attr_to_rest_field['keys_property'] + } + + model = PaddedModel(keys_property="value") + + # Should use original_tsp_name when available + assert attribute_list(model) == ["keys"] + assert as_attribute_dict(model) == {"keys": "value"} + + def test_3b_same_wire_padded_attr_with_original_readonly_regular(self): + """Wire=original, padded attr, original present, readonly, regular model""" + + class ReadonlyPaddedModel(HybridModel): + keys_property: str = rest_field(visibility=["read"], original_tsp_name="keys") + + model = ReadonlyPaddedModel(keys_property="value") + + # Should use original_tsp_name, excluded when exclude_readonly=True + assert attribute_list(model) == ["keys"] + assert as_attribute_dict(model) == {"keys": "value"} + assert as_attribute_dict(model, exclude_readonly=True) == {} + + def test_4a_different_wire_padded_attr_with_original_readwrite_regular(self): + """Wire≠original, padded attr, original present, readwrite, regular model""" + + class DifferentWirePaddedModel(HybridModel): + keys_property: str = rest_field(name="keysWire", original_tsp_name="keys") + + model = DifferentWirePaddedModel(keys_property="value") + + # Should use original_tsp_name + assert attribute_list(model) == ["keys"] + assert as_attribute_dict(model) == {"keys": "value"} + # Verify wire uses different name + assert dict(model) == {"keysWire": "value"} + + def test_4b_different_wire_padded_attr_with_original_readonly_regular(self): + """Wire≠original, padded attr, original present, readonly, regular model""" + + class ReadonlyDifferentWirePaddedModel(HybridModel): + keys_property: str = rest_field( + name="keysWire", + visibility=["read"], + original_tsp_name="keys" + ) + + model = ReadonlyDifferentWirePaddedModel(keys_property="value") + + # Should use original_tsp_name, excluded when exclude_readonly=True + assert attribute_list(model) == ["keys"] + assert as_attribute_dict(model) == {"keys": "value"} + assert as_attribute_dict(model, exclude_readonly=True) == {} + + # ========== DIMENSION 5: STRUCTURE VARIATIONS ========== + + def test_5a_nested_model_backcompat_recursive(self): + """Nested models with mixed backcompat scenarios""" + + class NestedBackcompatModel(HybridModel): + keys_property: str = rest_field(name="keysWire", original_tsp_name="keys") + normal_field: str = rest_field(name="normalWire") + + class ParentModel(HybridModel): + nested: NestedBackcompatModel = rest_field() + type_property: str = rest_field(original_tsp_name="type") + + nested_model = NestedBackcompatModel( + keys_property="nested_keys", + normal_field="nested_normal" + ) + parent_model = ParentModel(nested=nested_model, type_property="parent_type") + + # Test nested model independently + nested_attrs = attribute_list(nested_model) + assert set(nested_attrs) == {"keys", "normal_field"} + + nested_dict = as_attribute_dict(nested_model) + assert nested_dict == {"keys": "nested_keys", "normal_field": "nested_normal"} + + # Test parent model with recursive backcompat + parent_attrs = attribute_list(parent_model) + assert set(parent_attrs) == {"nested", "type"} + + parent_dict = as_attribute_dict(parent_model) + expected_parent = { + "nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, + "type": "parent_type" + } + assert parent_dict == expected_parent + + def test_6a_flattened_container_with_backcompat(self): + """Flattened property where container has backcompat (keys_property → keys)""" + + # Helper model for flattening content + class ContentModel(HybridModel): + name: str = rest_field() + description: str = rest_field() + + class FlattenedContainerModel(HybridModel): + id: str = rest_field() + keys_property: ContentModel = rest_field(original_tsp_name="keys") + + __flattened_items = ["name", "description"] + + def __init__(self, *args, **kwargs): + _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} + super().__init__(*args, **kwargs) + for k, v in _flattened.items(): + setattr(self, k, v) + + def __getattr__(self, name: str): + if name in self.__flattened_items and self.keys_property is not None: + return getattr(self.keys_property, name) + raise AttributeError(f"No attribute '{name}'") + + def __setattr__(self, key: str, value): + if key in self.__flattened_items: + if self.keys_property is None: + self.keys_property = ContentModel() + setattr(self.keys_property, key, value) + else: + super().__setattr__(key, value) + + model = FlattenedContainerModel( + id="test_id", + name="flattened_name", + description="flattened_desc" + ) + + # Flattened items should appear at top level + attrs = attribute_list(model) + assert set(attrs) == {"id", "name", "description"} + + # Flattened dict should use top-level names + attr_dict = as_attribute_dict(model) + expected = {"id": "test_id", "name": "flattened_name", "description": "flattened_desc"} + assert attr_dict == expected + + def test_6b_flattened_properties_with_backcompat(self): + """Flattened properties themselves have backcompat (type_property → type)""" + + class BackcompatContentModel(HybridModel): + type_property: str = rest_field(name="typeWire", original_tsp_name="type") + class_property: str = rest_field(name="classWire", original_tsp_name="class") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._attr_to_rest_field['type_property']._original_tsp_name = "type" + self._attr_to_rest_field['class_property']._original_tsp_name = "class" + self._backcompat_attr_to_rest_field = { + "type": self._attr_to_rest_field['type_property'], + "class": self._attr_to_rest_field['class_property'] + } + + class FlattenedPropsBackcompatModel(HybridModel): + name: str = rest_field() + properties: BackcompatContentModel = rest_field() + + __flattened_items = ["type", "class"] # Use original names + + def __init__(self, *args, **kwargs): + _flattened = {} + for item in ["type", "class"]: + if item in kwargs: + _flattened[item] = kwargs.pop(item) + super().__init__(*args, **kwargs) + for k, v in _flattened.items(): + setattr(self, k, v) + + def __getattr__(self, name: str): + if name in self.__flattened_items and self.properties is not None: + attr_map = {"type": "type_property", "class": "class_property"} + return getattr(self.properties, attr_map[name]) + raise AttributeError(f"No attribute '{name}'") + + def __setattr__(self, key: str, value): + if key in self.__flattened_items: + if self.properties is None: + self.properties = BackcompatContentModel() + attr_map = {"type": "type_property", "class": "class_property"} + setattr(self.properties, attr_map[key], value) + else: + super().__setattr__(key, value) + + model = FlattenedPropsBackcompatModel( + name="test_name", + type="test_type", + **{"class": "test_class"} + ) + + # Should use original names for flattened properties + attrs = attribute_list(model) + assert set(attrs) == {"name", "type", "class"} + + attr_dict = as_attribute_dict(model) + expected = {"name": "test_name", "type": "test_type", "class": "test_class"} + assert attr_dict == expected + + def test_6c_flattened_with_readonly_exclusion(self): + """Flattened model with readonly properties and exclude_readonly behavior""" + + class ReadonlyContentModel(HybridModel): + readonly_field: str = rest_field( + name="readonlyWire", + visibility=["read"], + original_tsp_name="readonly_prop" + ) + readwrite_field: str = rest_field( + name="readwriteWire", + original_tsp_name="readwrite_prop" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._attr_to_rest_field['readonly_field']._original_tsp_name = "readonly_prop" + self._attr_to_rest_field['readwrite_field']._original_tsp_name = "readwrite_prop" + self._backcompat_attr_to_rest_field = { + "readonly_prop": self._attr_to_rest_field['readonly_field'], + "readwrite_prop": self._attr_to_rest_field['readwrite_field'] + } + + class FlattenedReadonlyModel(HybridModel): + id: str = rest_field() + content: ReadonlyContentModel = rest_field() + + __flattened_items = ["readonly_prop", "readwrite_prop"] + + def __init__(self, *args, **kwargs): + _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} + super().__init__(*args, **kwargs) + for k, v in _flattened.items(): + setattr(self, k, v) + + def __getattr__(self, name: str): + if name in self.__flattened_items and self.content is not None: + attr_map = { + "readonly_prop": "readonly_field", + "readwrite_prop": "readwrite_field" + } + return getattr(self.content, attr_map[name]) + raise AttributeError(f"No attribute '{name}'") + + def __setattr__(self, key: str, value): + if key in self.__flattened_items: + if self.content is None: + self.content = ReadonlyContentModel() + attr_map = { + "readonly_prop": "readonly_field", + "readwrite_prop": "readwrite_field" + } + setattr(self.content, attr_map[key], value) + else: + super().__setattr__(key, value) + + model = FlattenedReadonlyModel( + id="test_id", + readonly_prop="readonly_value", + readwrite_prop="readwrite_value" + ) + + # All properties included by default + full_dict = as_attribute_dict(model, exclude_readonly=False) + expected_full = { + "id": "test_id", + "readonly_prop": "readonly_value", + "readwrite_prop": "readwrite_value" + } + assert full_dict == expected_full + + # Readonly properties excluded when requested + filtered_dict = as_attribute_dict(model, exclude_readonly=True) + expected_filtered = { + "id": "test_id", + "readwrite_prop": "readwrite_value" + } + assert filtered_dict == expected_filtered + + # ========== EDGE CASES ========== + + def test_mixed_combinations_comprehensive(self): + """Comprehensive test mixing all backcompat scenarios in one model""" + + class ComprehensiveModel(HybridModel): + # Case 1: Normal field, same wire name, no original + normal_field: str = rest_field() + + # Case 2: Normal field, different wire name, no original + different_wire: str = rest_field(name="wireNameDifferent") + + # Case 3: Padded field with original, same wire name + keys_property: str = rest_field(original_tsp_name="keys") + + # Case 4: Padded field with original, different wire name + type_property: str = rest_field(name="typeWire", original_tsp_name="type") + + # Case 5: Readonly field with original + readonly_class: str = rest_field( + name="classWire", + visibility=["read"], + original_tsp_name="class" + ) + + model = ComprehensiveModel( + normal_field="normal", + different_wire="different", + keys_property="keys_val", + type_property="type_val", + readonly_class="class_val" + ) + + # attribute_list should use backcompat names where available + attrs = attribute_list(model) + expected_attrs = {"normal_field", "different_wire", "keys", "type", "class"} + assert set(attrs) == expected_attrs + + # Full as_attribute_dict + full_dict = as_attribute_dict(model) + expected_full = { + "normal_field": "normal", + "different_wire": "different", + "keys": "keys_val", + "type": "type_val", + "class": "class_val" + } + assert full_dict == expected_full + + # Exclude readonly + filtered_dict = as_attribute_dict(model, exclude_readonly=True) + expected_filtered = { + "normal_field": "normal", + "different_wire": "different", + "keys": "keys_val", + "type": "type_val" + # "class" excluded because it's readonly + } + assert filtered_dict == expected_filtered + + # Verify wire representations use correct wire names + wire_dict = dict(model) + expected_wire = { + "normal_field": "normal", # same as attr + "wireNameDifferent": "different", # different wire name + "keys_property": "keys_val", # same as attr (padded) + "typeWire": "type_val", # different wire name + "classWire": "class_val" # different wire name + } + assert wire_dict == expected_wire + + def test_no_backcompat_fallback(self): + """Test fallback behavior when no backcompat mapping exists""" + + class NoBackcompatModel(HybridModel): + padded_attr: str = rest_field(name="wireField") + # Note: No original_tsp_name set, so no backcompat should occur + + model = NoBackcompatModel(padded_attr="value") + + # Should fall back to using actual attribute names + assert attribute_list(model) == ["padded_attr"] + assert as_attribute_dict(model) == {"padded_attr": "value"} + assert dict(model) == {"wireField": "value"} + + def test_property_with_padding_in_actual_name(self): + """Test handling of properties that have padding in their actual attribute names""" + + class PaddingInNameModel(HybridModel): + keys_property: str = rest_field(name="myKeys") + + model = PaddingInNameModel(keys_property="value") + # Should use actual attribute name since no original_tsp_name is set + assert attribute_list(model) == ["keys_property"] + assert as_attribute_dict(model) == {"keys_property": "value"} + assert dict(model) == {"myKeys": "value"} From 5ab99697a3b2c9a59a1b500f07c003fb211148e8 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 17 Nov 2025 16:15:28 -0500 Subject: [PATCH 02/11] add changelog --- sdk/core/azure-core/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 160b820c1a97..a5b08ea4f0b1 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- Fix `attribute_list` and `as_attribute_dict` to return original model attribute name in cases where we pad the name now but used to not pad #44084 + ### Other Changes - Updated `BearerTokenCredentialPolicy` and `AsyncBearerTokenCredentialPolicy` to set the `enable_cae` parameter to `True` by default. This change enables Continuous Access Evaluation (CAE) for all token requests made through these policies. #42941 From 7e9e659a4e4f895f46f24d6ab077c6cc993aeed9 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 18 Nov 2025 13:40:20 -0500 Subject: [PATCH 03/11] lint and run black --- sdk/core/azure-core/azure/core/serialization.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index ea312929739c..ae2c21348e58 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -365,10 +365,10 @@ def _get_flattened_attribute(obj: Any) -> Optional[str]: # Check if this property contains a nested model if not hasattr(v, '_class_type'): continue - + # Get backcompat names from the nested model nested_backcompat_names = set(_get_backcompat_attr_to_rest_field(v._class_type).keys()) # pylint: disable=protected-access - + # If any flattened items match the backcompat names in the nested model, this is our flattened attribute if flattened_items_set.intersection(nested_backcompat_names): return k @@ -446,13 +446,13 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, # Get the backcompat name for this attribute original_tsp_name = getattr(rest_field, "_original_tsp_name", None) backcompat_name = original_tsp_name if original_tsp_name else actual_attr_name - + if backcompat_name == flattened_attribute: flattened_actual_attr = actual_attr_name break except StopIteration: pass - + for attr_name, rest_field in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access if exclude_readonly and _is_readonly(rest_field): @@ -467,7 +467,7 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, rest_to_attr[nested_field._rest_name] = flattened_name # pylint: disable=protected-access else: rest_to_attr[rest_field._rest_name] = attr_name # pylint: disable=protected-access - + for k, v in obj.items(): if exclude_readonly and k in readonly_props: # pyright: ignore continue @@ -478,7 +478,9 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, mapped_name = rest_to_attr.get(fk, fk) if mapped_name in (flattened_items or []): # Check if this flattened item should be excluded due to readonly - nested_backcompat_map = _get_backcompat_attr_to_rest_field(getattr(obj._attr_to_rest_field[flattened_actual_attr], '_class_type')) # pylint: disable=protected-access + nested_backcompat_map = _get_backcompat_attr_to_rest_field( + getattr(obj._attr_to_rest_field[flattened_actual_attr], '_class_type') + ) # pylint: disable=protected-access if mapped_name in nested_backcompat_map: nested_field = nested_backcompat_map[mapped_name] if exclude_readonly and _is_readonly(nested_field): From 96f03021a8ae5ccf052a83c406d10f0de23fbe52 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 18 Nov 2025 13:46:01 -0500 Subject: [PATCH 04/11] run black --- .../azure-core/azure/core/serialization.py | 34 +- .../modeltypes/_utils/model_base.py | 5 +- .../azure-core/tests/test_serialization.py | 302 ++++++++---------- 3 files changed, 154 insertions(+), 187 deletions(-) diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index ae2c21348e58..aef3aecb8b0a 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -331,6 +331,7 @@ def _as_attribute_dict_value(v: Any, *, exclude_readonly: bool = False) -> Any: return {dk: _as_attribute_dict_value(dv, exclude_readonly=exclude_readonly) for dk, dv in v.items()} return as_attribute_dict(v, exclude_readonly=exclude_readonly) if is_generated_model(v) else v + def _get_backcompat_attr_to_rest_field(obj: Any) -> Dict[str, Any]: """Get the backcompat attribute to rest field mapping for a generated TypeSpec model. @@ -343,6 +344,7 @@ def _get_backcompat_attr_to_rest_field(obj: Any) -> Dict[str, Any]: except AttributeError: return obj._attr_to_rest_field # pylint: disable=protected-access + def _get_flattened_attribute(obj: Any) -> Optional[str]: """Get the name of the flattened attribute in a generated TypeSpec model if one exists. @@ -363,11 +365,13 @@ def _get_flattened_attribute(obj: Any) -> Optional[str]: for k, v in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access try: # Check if this property contains a nested model - if not hasattr(v, '_class_type'): + if not hasattr(v, "_class_type"): continue # Get backcompat names from the nested model - nested_backcompat_names = set(_get_backcompat_attr_to_rest_field(v._class_type).keys()) # pylint: disable=protected-access + nested_backcompat_names = set( + _get_backcompat_attr_to_rest_field(v._class_type).keys() # pylint: disable=protected-access + ) # If any flattened items match the backcompat names in the nested model, this is our flattened attribute if flattened_items_set.intersection(nested_backcompat_names): @@ -393,7 +397,7 @@ def attribute_list(obj: Any) -> List[str]: return list(obj._attribute_map.keys()) # pylint: disable=protected-access flattened_attribute = _get_flattened_attribute(obj) retval: List[str] = [] - for attr_name, rest_field in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access + for attr_name in _get_backcompat_attr_to_rest_field(obj).keys(): # pylint: disable=protected-access if flattened_attribute == attr_name: # For flattened attributes, return the flattened item names from __flattened_items try: @@ -407,7 +411,9 @@ def attribute_list(obj: Any) -> List[str]: return retval -def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, Any]: +def as_attribute_dict( # pylint: disable=too-many-branches,too-many-statements + obj: Any, *, exclude_readonly: bool = False +) -> Dict[str, Any]: """Convert an object to a dictionary of its attributes. Made solely for backcompatibility with the legacy `.as_dict()` on msrest models. @@ -426,7 +432,7 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, if hasattr(obj, "_attribute_map"): # msrest generated model return obj.as_dict(keep_readonly=not exclude_readonly) - try: + try: # pylint: disable=too-many-nested-blocks # now we're a typespec generated model result = {} readonly_props = set() @@ -453,14 +459,18 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, except StopIteration: pass - for attr_name, rest_field in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access + for attr_name, rest_field in _get_backcompat_attr_to_rest_field( + obj + ).items(): # pylint: disable=protected-access if exclude_readonly and _is_readonly(rest_field): # if we're excluding readonly properties, we need to track them readonly_props.add(rest_field._rest_name) # pylint: disable=protected-access if flattened_attribute == attr_name: # For flattened attributes, map flattened item names directly to their nested field names - nested_backcompat_map = _get_backcompat_attr_to_rest_field(rest_field._class_type) # pylint: disable=protected-access + nested_backcompat_map = _get_backcompat_attr_to_rest_field( + rest_field._class_type # pylint: disable=protected-access + ) for flattened_name in flattened_items or []: if flattened_name in nested_backcompat_map: nested_field = nested_backcompat_map[flattened_name] @@ -473,14 +483,18 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, continue if k == flattened_actual_attr: # For flattened attributes, extract values from nested model using backcompat names - if hasattr(v, 'items'): + if hasattr(v, "items"): for fk, fv in v.items(): mapped_name = rest_to_attr.get(fk, fk) if mapped_name in (flattened_items or []): # Check if this flattened item should be excluded due to readonly nested_backcompat_map = _get_backcompat_attr_to_rest_field( - getattr(obj._attr_to_rest_field[flattened_actual_attr], '_class_type') - ) # pylint: disable=protected-access + getattr( + # pylint: disable-next=protected-access + obj._attr_to_rest_field[flattened_actual_attr], + "_class_type", + ) + ) if mapped_name in nested_backcompat_map: nested_field = nested_backcompat_map[mapped_name] if exclude_readonly and _is_readonly(nested_field): diff --git a/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py b/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py index 9172543a2791..4d6a099c61ba 100644 --- a/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py +++ b/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py @@ -655,8 +655,8 @@ def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> Self: rf._rest_name_input = attr cls._attr_to_rest_field: typing.Dict[str, _RestField] = dict(attr_to_rest_field.items()) cls._backcompat_attr_to_rest_field: typing.Dict[str, _RestField] = { - Model._get_backcompat_attribute_name(cls._attr_to_rest_field, attr): rf for attr, rf in cls - ._attr_to_rest_field.items() + Model._get_backcompat_attribute_name(cls._attr_to_rest_field, attr): rf + for attr, rf in cls._attr_to_rest_field.items() } cls._calculated.add(f"{cls.__module__}.{cls.__qualname__}") @@ -677,7 +677,6 @@ def _get_backcompat_attribute_name(cls, _attr_to_rest_field: typing.Dict[str, "_ return original_tsp_name return attr_name - @classmethod def _get_discriminator(cls, exist_discriminators) -> typing.Optional["_RestField"]: for v in cls.__dict__.values(): diff --git a/sdk/core/azure-core/tests/test_serialization.py b/sdk/core/azure-core/tests/test_serialization.py index 3fb7d1a4c80e..f41d422895ea 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -1649,14 +1649,14 @@ def deserializer_b(cls, data: Dict[str, Any]) -> ModelB: class TestBackcompatPropertyMatrix: """ Systematic test matrix for DPG model property backcompat scenarios. - + Tests all combinations of 5 key dimensions: 1. wireName: same/different from attr_name - 2. attr_name: normal/padded (reserved word) + 2. attr_name: normal/padded (reserved word) 3. original_tsp_name: None/present (TSP name before padding) 4. visibility: readonly/readwrite (affects exclude_readonly) 5. structure: regular/nested/flattened models - + COMPLETE TEST MATRIX: ┌───────┬─────────────┬──────────────┬─────────────────┬────────────┬──────────────┬─────────────────────────────┐ │ Test │ Wire Name │ Attr Name │ Original TSP │ Visibility │ Structure │ Expected Behavior │ @@ -1675,189 +1675,177 @@ class TestBackcompatPropertyMatrix: │ 6c │ various │ mixed │ mixed │ readonly │ flat-mixed │ flattened + exclude │ └───────┴─────────────┴──────────────┴─────────────────┴────────────┴──────────────┴─────────────────────────────┘ """ - + # ========== DIMENSION 1-4 COMBINATIONS: REGULAR STRUCTURE ========== - + def test_1a_same_wire_normal_attr_no_original_readwrite_regular(self): """Wire=attr, normal attr, no original, readwrite, regular model""" - + class RegularModel(HybridModel): field_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) - + model = RegularModel(field_name="value") - + # Should use attr_name (same as wire name) assert attribute_list(model) == ["field_name"] assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {"field_name": "value"} - + def test_1b_same_wire_normal_attr_no_original_readonly_regular(self): """Wire=attr, normal attr, no original, readonly, regular model""" - + class ReadonlyModel(HybridModel): field_name: str = rest_field(visibility=["read"]) - + model = ReadonlyModel(field_name="value") - + # Should use attr_name, but excluded when exclude_readonly=True assert attribute_list(model) == ["field_name"] assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - + def test_2a_different_wire_normal_attr_no_original_readwrite_regular(self): """Wire≠attr, normal attr, no original, readwrite, regular model""" - + class DifferentWireModel(HybridModel): client_field: str = rest_field(name="wireField", visibility=["read", "create", "update", "delete", "query"]) - + model = DifferentWireModel(client_field="value") - + # Should use attr_name (wire name is different) assert attribute_list(model) == ["client_field"] assert as_attribute_dict(model) == {"client_field": "value"} # Verify wire representation uses different name assert dict(model) == {"wireField": "value"} - + def test_2b_different_wire_normal_attr_no_original_readonly_regular(self): """Wire≠attr, normal attr, no original, readonly, regular model""" - + class ReadonlyDifferentWireModel(HybridModel): client_field: str = rest_field(name="wireField", visibility=["read"]) - + model = ReadonlyDifferentWireModel(client_field="value") - + # Should use attr_name, excluded when exclude_readonly=True assert attribute_list(model) == ["client_field"] assert as_attribute_dict(model) == {"client_field": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - + def test_3a_same_wire_padded_attr_with_original_readwrite_regular(self): """Wire=original, padded attr, original present, readwrite, regular model""" - + class PaddedModel(HybridModel): keys_property: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Set original TSP name for backcompat - self._attr_to_rest_field['keys_property']._original_tsp_name = "keys" + self._attr_to_rest_field["keys_property"]._original_tsp_name = "keys" # Create backcompat mapping - self._backcompat_attr_to_rest_field = { - "keys": self._attr_to_rest_field['keys_property'] - } - + self._backcompat_attr_to_rest_field = {"keys": self._attr_to_rest_field["keys_property"]} + model = PaddedModel(keys_property="value") - + # Should use original_tsp_name when available assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} - + def test_3b_same_wire_padded_attr_with_original_readonly_regular(self): """Wire=original, padded attr, original present, readonly, regular model""" - + class ReadonlyPaddedModel(HybridModel): keys_property: str = rest_field(visibility=["read"], original_tsp_name="keys") - + model = ReadonlyPaddedModel(keys_property="value") - + # Should use original_tsp_name, excluded when exclude_readonly=True assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - + def test_4a_different_wire_padded_attr_with_original_readwrite_regular(self): """Wire≠original, padded attr, original present, readwrite, regular model""" - + class DifferentWirePaddedModel(HybridModel): keys_property: str = rest_field(name="keysWire", original_tsp_name="keys") - + model = DifferentWirePaddedModel(keys_property="value") - + # Should use original_tsp_name assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} # Verify wire uses different name assert dict(model) == {"keysWire": "value"} - + def test_4b_different_wire_padded_attr_with_original_readonly_regular(self): """Wire≠original, padded attr, original present, readonly, regular model""" - + class ReadonlyDifferentWirePaddedModel(HybridModel): - keys_property: str = rest_field( - name="keysWire", - visibility=["read"], - original_tsp_name="keys" - ) - + keys_property: str = rest_field(name="keysWire", visibility=["read"], original_tsp_name="keys") + model = ReadonlyDifferentWirePaddedModel(keys_property="value") - - # Should use original_tsp_name, excluded when exclude_readonly=True + + # Should use original_tsp_name, excluded when exclude_readonly=True assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - + # ========== DIMENSION 5: STRUCTURE VARIATIONS ========== - + def test_5a_nested_model_backcompat_recursive(self): """Nested models with mixed backcompat scenarios""" - + class NestedBackcompatModel(HybridModel): keys_property: str = rest_field(name="keysWire", original_tsp_name="keys") normal_field: str = rest_field(name="normalWire") - + class ParentModel(HybridModel): nested: NestedBackcompatModel = rest_field() type_property: str = rest_field(original_tsp_name="type") - - nested_model = NestedBackcompatModel( - keys_property="nested_keys", - normal_field="nested_normal" - ) + + nested_model = NestedBackcompatModel(keys_property="nested_keys", normal_field="nested_normal") parent_model = ParentModel(nested=nested_model, type_property="parent_type") - + # Test nested model independently nested_attrs = attribute_list(nested_model) assert set(nested_attrs) == {"keys", "normal_field"} - + nested_dict = as_attribute_dict(nested_model) assert nested_dict == {"keys": "nested_keys", "normal_field": "nested_normal"} - + # Test parent model with recursive backcompat parent_attrs = attribute_list(parent_model) assert set(parent_attrs) == {"nested", "type"} - + parent_dict = as_attribute_dict(parent_model) - expected_parent = { - "nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, - "type": "parent_type" - } + expected_parent = {"nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, "type": "parent_type"} assert parent_dict == expected_parent - + def test_6a_flattened_container_with_backcompat(self): """Flattened property where container has backcompat (keys_property → keys)""" - + # Helper model for flattening content class ContentModel(HybridModel): name: str = rest_field() description: str = rest_field() - + class FlattenedContainerModel(HybridModel): id: str = rest_field() keys_property: ContentModel = rest_field(original_tsp_name="keys") - + __flattened_items = ["name", "description"] - + def __init__(self, *args, **kwargs): _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} super().__init__(*args, **kwargs) for k, v in _flattened.items(): setattr(self, k, v) - + def __getattr__(self, name: str): if name in self.__flattened_items and self.keys_property is not None: return getattr(self.keys_property, name) raise AttributeError(f"No attribute '{name}'") - + def __setattr__(self, key: str, value): if key in self.__flattened_items: if self.keys_property is None: @@ -1865,44 +1853,40 @@ def __setattr__(self, key: str, value): setattr(self.keys_property, key, value) else: super().__setattr__(key, value) - - model = FlattenedContainerModel( - id="test_id", - name="flattened_name", - description="flattened_desc" - ) - + + model = FlattenedContainerModel(id="test_id", name="flattened_name", description="flattened_desc") + # Flattened items should appear at top level attrs = attribute_list(model) assert set(attrs) == {"id", "name", "description"} - + # Flattened dict should use top-level names attr_dict = as_attribute_dict(model) expected = {"id": "test_id", "name": "flattened_name", "description": "flattened_desc"} assert attr_dict == expected - + def test_6b_flattened_properties_with_backcompat(self): """Flattened properties themselves have backcompat (type_property → type)""" - + class BackcompatContentModel(HybridModel): type_property: str = rest_field(name="typeWire", original_tsp_name="type") class_property: str = rest_field(name="classWire", original_tsp_name="class") - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._attr_to_rest_field['type_property']._original_tsp_name = "type" - self._attr_to_rest_field['class_property']._original_tsp_name = "class" + self._attr_to_rest_field["type_property"]._original_tsp_name = "type" + self._attr_to_rest_field["class_property"]._original_tsp_name = "class" self._backcompat_attr_to_rest_field = { - "type": self._attr_to_rest_field['type_property'], - "class": self._attr_to_rest_field['class_property'] + "type": self._attr_to_rest_field["type_property"], + "class": self._attr_to_rest_field["class_property"], } - + class FlattenedPropsBackcompatModel(HybridModel): name: str = rest_field() properties: BackcompatContentModel = rest_field() - + __flattened_items = ["type", "class"] # Use original names - + def __init__(self, *args, **kwargs): _flattened = {} for item in ["type", "class"]: @@ -1911,13 +1895,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for k, v in _flattened.items(): setattr(self, k, v) - + def __getattr__(self, name: str): if name in self.__flattened_items and self.properties is not None: attr_map = {"type": "type_property", "class": "class_property"} return getattr(self.properties, attr_map[name]) raise AttributeError(f"No attribute '{name}'") - + def __setattr__(self, key: str, value): if key in self.__flattened_items: if self.properties is None: @@ -1926,180 +1910,150 @@ def __setattr__(self, key: str, value): setattr(self.properties, attr_map[key], value) else: super().__setattr__(key, value) - - model = FlattenedPropsBackcompatModel( - name="test_name", - type="test_type", - **{"class": "test_class"} - ) - + + model = FlattenedPropsBackcompatModel(name="test_name", type="test_type", **{"class": "test_class"}) + # Should use original names for flattened properties attrs = attribute_list(model) assert set(attrs) == {"name", "type", "class"} - + attr_dict = as_attribute_dict(model) expected = {"name": "test_name", "type": "test_type", "class": "test_class"} assert attr_dict == expected - + def test_6c_flattened_with_readonly_exclusion(self): """Flattened model with readonly properties and exclude_readonly behavior""" - + class ReadonlyContentModel(HybridModel): readonly_field: str = rest_field( - name="readonlyWire", - visibility=["read"], - original_tsp_name="readonly_prop" + name="readonlyWire", visibility=["read"], original_tsp_name="readonly_prop" ) - readwrite_field: str = rest_field( - name="readwriteWire", - original_tsp_name="readwrite_prop" - ) - + readwrite_field: str = rest_field(name="readwriteWire", original_tsp_name="readwrite_prop") + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._attr_to_rest_field['readonly_field']._original_tsp_name = "readonly_prop" - self._attr_to_rest_field['readwrite_field']._original_tsp_name = "readwrite_prop" + self._attr_to_rest_field["readonly_field"]._original_tsp_name = "readonly_prop" + self._attr_to_rest_field["readwrite_field"]._original_tsp_name = "readwrite_prop" self._backcompat_attr_to_rest_field = { - "readonly_prop": self._attr_to_rest_field['readonly_field'], - "readwrite_prop": self._attr_to_rest_field['readwrite_field'] + "readonly_prop": self._attr_to_rest_field["readonly_field"], + "readwrite_prop": self._attr_to_rest_field["readwrite_field"], } - + class FlattenedReadonlyModel(HybridModel): id: str = rest_field() content: ReadonlyContentModel = rest_field() - + __flattened_items = ["readonly_prop", "readwrite_prop"] - + def __init__(self, *args, **kwargs): _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} super().__init__(*args, **kwargs) for k, v in _flattened.items(): setattr(self, k, v) - + def __getattr__(self, name: str): if name in self.__flattened_items and self.content is not None: - attr_map = { - "readonly_prop": "readonly_field", - "readwrite_prop": "readwrite_field" - } + attr_map = {"readonly_prop": "readonly_field", "readwrite_prop": "readwrite_field"} return getattr(self.content, attr_map[name]) raise AttributeError(f"No attribute '{name}'") - + def __setattr__(self, key: str, value): if key in self.__flattened_items: if self.content is None: self.content = ReadonlyContentModel() - attr_map = { - "readonly_prop": "readonly_field", - "readwrite_prop": "readwrite_field" - } + attr_map = {"readonly_prop": "readonly_field", "readwrite_prop": "readwrite_field"} setattr(self.content, attr_map[key], value) else: super().__setattr__(key, value) - - model = FlattenedReadonlyModel( - id="test_id", - readonly_prop="readonly_value", - readwrite_prop="readwrite_value" - ) - + + model = FlattenedReadonlyModel(id="test_id", readonly_prop="readonly_value", readwrite_prop="readwrite_value") + # All properties included by default full_dict = as_attribute_dict(model, exclude_readonly=False) - expected_full = { - "id": "test_id", - "readonly_prop": "readonly_value", - "readwrite_prop": "readwrite_value" - } + expected_full = {"id": "test_id", "readonly_prop": "readonly_value", "readwrite_prop": "readwrite_value"} assert full_dict == expected_full - + # Readonly properties excluded when requested filtered_dict = as_attribute_dict(model, exclude_readonly=True) - expected_filtered = { - "id": "test_id", - "readwrite_prop": "readwrite_value" - } + expected_filtered = {"id": "test_id", "readwrite_prop": "readwrite_value"} assert filtered_dict == expected_filtered - + # ========== EDGE CASES ========== - + def test_mixed_combinations_comprehensive(self): """Comprehensive test mixing all backcompat scenarios in one model""" - + class ComprehensiveModel(HybridModel): # Case 1: Normal field, same wire name, no original normal_field: str = rest_field() - - # Case 2: Normal field, different wire name, no original + + # Case 2: Normal field, different wire name, no original different_wire: str = rest_field(name="wireNameDifferent") - + # Case 3: Padded field with original, same wire name keys_property: str = rest_field(original_tsp_name="keys") - + # Case 4: Padded field with original, different wire name type_property: str = rest_field(name="typeWire", original_tsp_name="type") - + # Case 5: Readonly field with original - readonly_class: str = rest_field( - name="classWire", - visibility=["read"], - original_tsp_name="class" - ) - + readonly_class: str = rest_field(name="classWire", visibility=["read"], original_tsp_name="class") + model = ComprehensiveModel( normal_field="normal", different_wire="different", keys_property="keys_val", type_property="type_val", - readonly_class="class_val" + readonly_class="class_val", ) - + # attribute_list should use backcompat names where available attrs = attribute_list(model) expected_attrs = {"normal_field", "different_wire", "keys", "type", "class"} assert set(attrs) == expected_attrs - + # Full as_attribute_dict full_dict = as_attribute_dict(model) expected_full = { "normal_field": "normal", "different_wire": "different", "keys": "keys_val", - "type": "type_val", - "class": "class_val" + "type": "type_val", + "class": "class_val", } assert full_dict == expected_full - + # Exclude readonly filtered_dict = as_attribute_dict(model, exclude_readonly=True) expected_filtered = { "normal_field": "normal", "different_wire": "different", "keys": "keys_val", - "type": "type_val" + "type": "type_val", # "class" excluded because it's readonly } assert filtered_dict == expected_filtered - + # Verify wire representations use correct wire names wire_dict = dict(model) expected_wire = { - "normal_field": "normal", # same as attr + "normal_field": "normal", # same as attr "wireNameDifferent": "different", # different wire name - "keys_property": "keys_val", # same as attr (padded) - "typeWire": "type_val", # different wire name - "classWire": "class_val" # different wire name + "keys_property": "keys_val", # same as attr (padded) + "typeWire": "type_val", # different wire name + "classWire": "class_val", # different wire name } assert wire_dict == expected_wire - + def test_no_backcompat_fallback(self): """Test fallback behavior when no backcompat mapping exists""" - + class NoBackcompatModel(HybridModel): padded_attr: str = rest_field(name="wireField") # Note: No original_tsp_name set, so no backcompat should occur - + model = NoBackcompatModel(padded_attr="value") - + # Should fall back to using actual attribute names assert attribute_list(model) == ["padded_attr"] assert as_attribute_dict(model) == {"padded_attr": "value"} @@ -2107,7 +2061,7 @@ class NoBackcompatModel(HybridModel): def test_property_with_padding_in_actual_name(self): """Test handling of properties that have padding in their actual attribute names""" - + class PaddingInNameModel(HybridModel): keys_property: str = rest_field(name="myKeys") From 4db5a195995374feecbfd133b63e72e9c942fe8f Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Tue, 18 Nov 2025 18:23:44 -0500 Subject: [PATCH 05/11] add get_old_attribute --- .../azure-core/azure/core/serialization.py | 152 +++++------ .../azure-core/tests/test_serialization.py | 250 ++++++++++-------- 2 files changed, 202 insertions(+), 200 deletions(-) diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index aef3aecb8b0a..fa080927f6ce 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -4,6 +4,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +# pylint: disable=protected-access import base64 from functools import partial from json import JSONEncoder @@ -19,6 +20,7 @@ "as_attribute_dict", "attribute_list", "TypeHandlerRegistry", + "get_old_attribute", ] TZ_UTC = timezone.utc @@ -317,7 +319,7 @@ def _is_readonly(p: Any) -> bool: :rtype: bool """ try: - return p._visibility == ["read"] # pylint: disable=protected-access + return p._visibility == ["read"] except AttributeError: return False @@ -332,17 +334,18 @@ def _as_attribute_dict_value(v: Any, *, exclude_readonly: bool = False) -> Any: return as_attribute_dict(v, exclude_readonly=exclude_readonly) if is_generated_model(v) else v -def _get_backcompat_attr_to_rest_field(obj: Any) -> Dict[str, Any]: - """Get the backcompat attribute to rest field mapping for a generated TypeSpec model. +def _get_backcompat_name(rest_field: Any, default_attr_name: str) -> str: + """Get the backcompat name for an attribute. - :param any obj: The object to get the mapping from. - :return: The backcompat attribute to rest field mapping. - :rtype: Dict[str, Any] + :param any rest_field: The rest field to get the backcompat name from. + :param str default_attr_name: The default attribute name to use if no backcompat name + :return: The backcompat name. + :rtype: str """ try: - return obj._backcompat_attr_to_rest_field # pylint: disable=protected-access + return rest_field._original_tsp_name or default_attr_name except AttributeError: - return obj._attr_to_rest_field # pylint: disable=protected-access + return default_attr_name def _get_flattened_attribute(obj: Any) -> Optional[str]: @@ -361,20 +364,9 @@ def _get_flattened_attribute(obj: Any) -> Optional[str]: if flattened_items is None: return None - flattened_items_set = set(flattened_items) - for k, v in _get_backcompat_attr_to_rest_field(obj).items(): # pylint: disable=protected-access + for k, v in obj._attr_to_rest_field.items(): try: - # Check if this property contains a nested model - if not hasattr(v, "_class_type"): - continue - - # Get backcompat names from the nested model - nested_backcompat_names = set( - _get_backcompat_attr_to_rest_field(v._class_type).keys() # pylint: disable=protected-access - ) - - # If any flattened items match the backcompat names in the nested model, this is our flattened attribute - if flattened_items_set.intersection(nested_backcompat_names): + if set(v._class_type._attr_to_rest_field.keys()).intersection(set(flattened_items)): return k except AttributeError: # if the attribute does not have _class_type, it is not a typespec generated model @@ -394,26 +386,18 @@ def attribute_list(obj: Any) -> List[str]: raise TypeError("Object is not a generated SDK model.") if hasattr(obj, "_attribute_map"): # msrest model - return list(obj._attribute_map.keys()) # pylint: disable=protected-access + return list(obj._attribute_map.keys()) flattened_attribute = _get_flattened_attribute(obj) retval: List[str] = [] - for attr_name in _get_backcompat_attr_to_rest_field(obj).keys(): # pylint: disable=protected-access + for attr_name, rest_field in obj._attr_to_rest_field.items(): if flattened_attribute == attr_name: - # For flattened attributes, return the flattened item names from __flattened_items - try: - flattened_items = getattr(obj, next(a for a in dir(obj) if "__flattened_items" in a), None) - if flattened_items: - retval.extend(flattened_items) - except StopIteration: - pass + retval.extend(attribute_list(rest_field._class_type)) else: - retval.append(attr_name) + retval.append(_get_backcompat_name(rest_field, attr_name)) return retval -def as_attribute_dict( # pylint: disable=too-many-branches,too-many-statements - obj: Any, *, exclude_readonly: bool = False -) -> Dict[str, Any]: +def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, Any]: """Convert an object to a dictionary of its attributes. Made solely for backcompatibility with the legacy `.as_dict()` on msrest models. @@ -432,7 +416,7 @@ def as_attribute_dict( # pylint: disable=too-many-branches,too-many-statements if hasattr(obj, "_attribute_map"): # msrest generated model return obj.as_dict(keep_readonly=not exclude_readonly) - try: # pylint: disable=too-many-nested-blocks + try: # now we're a typespec generated model result = {} readonly_props = set() @@ -440,73 +424,27 @@ def as_attribute_dict( # pylint: disable=too-many-branches,too-many-statements # create a reverse mapping from rest field name to attribute name rest_to_attr = {} flattened_attribute = _get_flattened_attribute(obj) - flattened_items = None - flattened_actual_attr = None # Store the actual attribute name for the flattened attribute - if flattened_attribute: - try: - flattened_items = getattr(obj, next(a for a in dir(obj) if "__flattened_items" in a), None) - # Find the actual attribute name that corresponds to the flattened attribute - # flattened_attribute could be either an actual attr name or a backcompat name - # We need to find the actual attr name that appears in obj.items() - for actual_attr_name, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access - # Get the backcompat name for this attribute - original_tsp_name = getattr(rest_field, "_original_tsp_name", None) - backcompat_name = original_tsp_name if original_tsp_name else actual_attr_name - - if backcompat_name == flattened_attribute: - flattened_actual_attr = actual_attr_name - break - except StopIteration: - pass - - for attr_name, rest_field in _get_backcompat_attr_to_rest_field( - obj - ).items(): # pylint: disable=protected-access + for attr_name, rest_field in obj._attr_to_rest_field.items(): if exclude_readonly and _is_readonly(rest_field): # if we're excluding readonly properties, we need to track them - readonly_props.add(rest_field._rest_name) # pylint: disable=protected-access + readonly_props.add(rest_field._rest_name) if flattened_attribute == attr_name: - # For flattened attributes, map flattened item names directly to their nested field names - nested_backcompat_map = _get_backcompat_attr_to_rest_field( - rest_field._class_type # pylint: disable=protected-access - ) - for flattened_name in flattened_items or []: - if flattened_name in nested_backcompat_map: - nested_field = nested_backcompat_map[flattened_name] - rest_to_attr[nested_field._rest_name] = flattened_name # pylint: disable=protected-access + for fk, fv in rest_field._class_type._attr_to_rest_field.items(): + rest_to_attr[fv._rest_name] = _get_backcompat_name(fv, fk) else: - rest_to_attr[rest_field._rest_name] = attr_name # pylint: disable=protected-access - + rest_to_attr[rest_field._rest_name] = _get_backcompat_name(rest_field, attr_name) for k, v in obj.items(): if exclude_readonly and k in readonly_props: # pyright: ignore continue - if k == flattened_actual_attr: - # For flattened attributes, extract values from nested model using backcompat names - if hasattr(v, "items"): - for fk, fv in v.items(): - mapped_name = rest_to_attr.get(fk, fk) - if mapped_name in (flattened_items or []): - # Check if this flattened item should be excluded due to readonly - nested_backcompat_map = _get_backcompat_attr_to_rest_field( - getattr( - # pylint: disable-next=protected-access - obj._attr_to_rest_field[flattened_actual_attr], - "_class_type", - ) - ) - if mapped_name in nested_backcompat_map: - nested_field = nested_backcompat_map[mapped_name] - if exclude_readonly and _is_readonly(nested_field): - continue - result[mapped_name] = _as_attribute_dict_value(fv, exclude_readonly=exclude_readonly) + if k == flattened_attribute: + for fk, fv in v.items(): + result[rest_to_attr.get(fk, fk)] = _as_attribute_dict_value(fv, exclude_readonly=exclude_readonly) else: is_multipart_file_input = False try: - is_multipart_file_input = next( # pylint: disable=protected-access - rf - for rf in _get_backcompat_attr_to_rest_field(obj).values() # pylint: disable=protected-access - if rf._rest_name == k # pylint: disable=protected-access + is_multipart_file_input = next( + rf for rf in obj._attr_to_rest_field.values() if rf._rest_name == k )._is_multipart_file_input except StopIteration: pass @@ -518,3 +456,35 @@ def as_attribute_dict( # pylint: disable=too-many-branches,too-many-statements except AttributeError as exc: # not a typespec generated model raise TypeError("Object must be a generated model instance.") from exc + + +def get_old_attribute(model: Any, field_name: str) -> Any: + """Get the value of an attribute using backcompat attribute access. + + This function takes a field name that may be a backcompat name (original TSP name) + and returns the value from the corresponding actual attribute on the model. + + :param any model: The model instance. + :param str field_name: The backcompat attribute name (from attribute_list). + :return: The value of the attribute. + :rtype: any + """ + if not is_generated_model(model): + raise TypeError("Object must be a generated model instance.") + + # Check if field_name is an original TSP name in the model + flattened_attribute = _get_flattened_attribute(model) + for attr_name, rest_field in model._attr_to_rest_field.items(): + # Check if field_name matches this attribute's original TSP name (regardless of flattening) + if _get_backcompat_name(rest_field, attr_name) == field_name: + return getattr(model, attr_name) + + # If this is a flattened attribute, check if field_name is an original TSP name inside it + if flattened_attribute == attr_name: + for fk, fv in rest_field._class_type._attr_to_rest_field.items(): + if _get_backcompat_name(fv, fk) == field_name: + # This is a flattened property - access the actual attribute name + return getattr(model, fk) + + # Fallback to direct attribute access + return getattr(model, field_name) diff --git a/sdk/core/azure-core/tests/test_serialization.py b/sdk/core/azure-core/tests/test_serialization.py index f41d422895ea..a59c5fabf0ba 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -11,7 +11,14 @@ from typing import Any, Dict, List, Optional, Union, Type from io import BytesIO -from azure.core.serialization import AzureJSONEncoder, NULL, as_attribute_dict, is_generated_model, attribute_list +from azure.core.serialization import ( + AzureJSONEncoder, + NULL, + as_attribute_dict, + get_old_attribute, + is_generated_model, + attribute_list, +) from azure.core.exceptions import DeserializationError import pytest from modeltypes._utils.model_base import ( @@ -1682,7 +1689,7 @@ def test_1a_same_wire_normal_attr_no_original_readwrite_regular(self): """Wire=attr, normal attr, no original, readwrite, regular model""" class RegularModel(HybridModel): - field_name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) + field_name: str = rest_field() model = RegularModel(field_name="value") @@ -1690,6 +1697,8 @@ class RegularModel(HybridModel): assert attribute_list(model) == ["field_name"] assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {"field_name": "value"} + assert getattr(model, "field_name") == "value" + assert get_old_attribute(model, "field_name") == "value" def test_1b_same_wire_normal_attr_no_original_readonly_regular(self): """Wire=attr, normal attr, no original, readonly, regular model""" @@ -1703,12 +1712,14 @@ class ReadonlyModel(HybridModel): assert attribute_list(model) == ["field_name"] assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} + assert getattr(model, "field_name") == "value" + assert get_old_attribute(model, "field_name") == "value" def test_2a_different_wire_normal_attr_no_original_readwrite_regular(self): """Wire≠attr, normal attr, no original, readwrite, regular model""" class DifferentWireModel(HybridModel): - client_field: str = rest_field(name="wireField", visibility=["read", "create", "update", "delete", "query"]) + client_field: str = rest_field(name="wireField") model = DifferentWireModel(client_field="value") @@ -1717,6 +1728,8 @@ class DifferentWireModel(HybridModel): assert as_attribute_dict(model) == {"client_field": "value"} # Verify wire representation uses different name assert dict(model) == {"wireField": "value"} + assert getattr(model, "client_field") == "value" + assert get_old_attribute(model, "client_field") == "value" def test_2b_different_wire_normal_attr_no_original_readonly_regular(self): """Wire≠attr, normal attr, no original, readonly, regular model""" @@ -1730,25 +1743,22 @@ class ReadonlyDifferentWireModel(HybridModel): assert attribute_list(model) == ["client_field"] assert as_attribute_dict(model) == {"client_field": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} + assert getattr(model, "client_field") == "value" + assert get_old_attribute(model, "client_field") == "value" def test_3a_same_wire_padded_attr_with_original_readwrite_regular(self): """Wire=original, padded attr, original present, readwrite, regular model""" class PaddedModel(HybridModel): - keys_property: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Set original TSP name for backcompat - self._attr_to_rest_field["keys_property"]._original_tsp_name = "keys" - # Create backcompat mapping - self._backcompat_attr_to_rest_field = {"keys": self._attr_to_rest_field["keys_property"]} + keys_property: str = rest_field(original_tsp_name="keys") model = PaddedModel(keys_property="value") # Should use original_tsp_name when available assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} + assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "value" + assert set(model.keys()) == {"keys_property"} def test_3b_same_wire_padded_attr_with_original_readonly_regular(self): """Wire=original, padded attr, original present, readonly, regular model""" @@ -1762,33 +1772,39 @@ class ReadonlyPaddedModel(HybridModel): assert attribute_list(model) == ["keys"] assert as_attribute_dict(model) == {"keys": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} + assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "value" + assert set(model.keys()) == {"keys_property"} def test_4a_different_wire_padded_attr_with_original_readwrite_regular(self): """Wire≠original, padded attr, original present, readwrite, regular model""" class DifferentWirePaddedModel(HybridModel): - keys_property: str = rest_field(name="keysWire", original_tsp_name="keys") + clear_property: str = rest_field(name="clearWire", original_tsp_name="clear") - model = DifferentWirePaddedModel(keys_property="value") + model = DifferentWirePaddedModel(clear_property="value") # Should use original_tsp_name - assert attribute_list(model) == ["keys"] - assert as_attribute_dict(model) == {"keys": "value"} + assert attribute_list(model) == ["clear"] + assert as_attribute_dict(model) == {"clear": "value"} # Verify wire uses different name - assert dict(model) == {"keysWire": "value"} + assert dict(model) == {"clearWire": "value"} + assert getattr(model, "clear_property") == get_old_attribute(model, "clear") == "value" + assert set(model.keys()) == {"clearWire"} def test_4b_different_wire_padded_attr_with_original_readonly_regular(self): """Wire≠original, padded attr, original present, readonly, regular model""" class ReadonlyDifferentWirePaddedModel(HybridModel): - keys_property: str = rest_field(name="keysWire", visibility=["read"], original_tsp_name="keys") + pop_property: str = rest_field(name="popWire", visibility=["read"], original_tsp_name="pop") - model = ReadonlyDifferentWirePaddedModel(keys_property="value") + model = ReadonlyDifferentWirePaddedModel(pop_property="value") # Should use original_tsp_name, excluded when exclude_readonly=True - assert attribute_list(model) == ["keys"] - assert as_attribute_dict(model) == {"keys": "value"} + assert attribute_list(model) == ["pop"] + assert as_attribute_dict(model) == {"pop": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} + assert getattr(model, "pop_property") == get_old_attribute(model, "pop") == "value" + assert set(model.keys()) == {"popWire"} # ========== DIMENSION 5: STRUCTURE VARIATIONS ========== @@ -1801,10 +1817,10 @@ class NestedBackcompatModel(HybridModel): class ParentModel(HybridModel): nested: NestedBackcompatModel = rest_field() - type_property: str = rest_field(original_tsp_name="type") + items_property: str = rest_field(name="itemsWire", original_tsp_name="items") nested_model = NestedBackcompatModel(keys_property="nested_keys", normal_field="nested_normal") - parent_model = ParentModel(nested=nested_model, type_property="parent_type") + parent_model = ParentModel(nested=nested_model, items_property="parent_items") # Test nested model independently nested_attrs = attribute_list(nested_model) @@ -1815,12 +1831,22 @@ class ParentModel(HybridModel): # Test parent model with recursive backcompat parent_attrs = attribute_list(parent_model) - assert set(parent_attrs) == {"nested", "type"} + assert set(parent_attrs) == {"nested", "items"} parent_dict = as_attribute_dict(parent_model) - expected_parent = {"nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, "type": "parent_type"} + expected_parent = {"nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, "items": "parent_items"} assert parent_dict == expected_parent + assert getattr(nested_model, "keys_property") == get_old_attribute(nested_model, "keys") == "nested_keys" + assert getattr(parent_model, "items_property") == get_old_attribute(parent_model, "items") == "parent_items" + + assert set(nested_model.keys()) == {"keysWire", "normalWire"} + assert set(nested_model.items()) == {("keysWire", "nested_keys"), ("normalWire", "nested_normal")} + assert set(parent_model.keys()) == {"nested", "itemsWire"} + assert len(parent_model.items()) == 2 + assert ("nested", parent_model.nested) in parent_model.items() + assert ("itemsWire", "parent_items") in parent_model.items() + def test_6a_flattened_container_with_backcompat(self): """Flattened property where container has backcompat (keys_property → keys)""" @@ -1831,26 +1857,28 @@ class ContentModel(HybridModel): class FlattenedContainerModel(HybridModel): id: str = rest_field() - keys_property: ContentModel = rest_field(original_tsp_name="keys") + update_property: ContentModel = rest_field(original_tsp_name="update") __flattened_items = ["name", "description"] - def __init__(self, *args, **kwargs): - _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} + def __init__(self, *args: Any, **kwargs: Any) -> None: + _flattened_input = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} super().__init__(*args, **kwargs) - for k, v in _flattened.items(): + for k, v in _flattened_input.items(): setattr(self, k, v) - def __getattr__(self, name: str): - if name in self.__flattened_items and self.keys_property is not None: - return getattr(self.keys_property, name) - raise AttributeError(f"No attribute '{name}'") + def __getattr__(self, name: str) -> Any: + if name in self.__flattened_items: + if self.update_property is None: + return None + return getattr(self.update_property, name) + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - def __setattr__(self, key: str, value): + def __setattr__(self, key: str, value: Any) -> None: if key in self.__flattened_items: - if self.keys_property is None: - self.keys_property = ContentModel() - setattr(self.keys_property, key, value) + if self.update_property is None: + self.update_property = self._attr_to_rest_field["update_property"]._class_type() + setattr(self.update_property, key, value) else: super().__setattr__(key, value) @@ -1859,125 +1887,123 @@ def __setattr__(self, key: str, value): # Flattened items should appear at top level attrs = attribute_list(model) assert set(attrs) == {"id", "name", "description"} + assert getattr(model, "name") == "flattened_name" + assert getattr(model, "description") == "flattened_desc" # Flattened dict should use top-level names attr_dict = as_attribute_dict(model) expected = {"id": "test_id", "name": "flattened_name", "description": "flattened_desc"} assert attr_dict == expected + assert get_old_attribute(model, "update") is model.update_property + assert get_old_attribute(model, "update").name == "flattened_name" + assert get_old_attribute(model, "update").description == "flattened_desc" + + assert set(model.keys()) == {"id", "update_property"} + def test_6b_flattened_properties_with_backcompat(self): """Flattened properties themselves have backcompat (type_property → type)""" class BackcompatContentModel(HybridModel): - type_property: str = rest_field(name="typeWire", original_tsp_name="type") - class_property: str = rest_field(name="classWire", original_tsp_name="class") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._attr_to_rest_field["type_property"]._original_tsp_name = "type" - self._attr_to_rest_field["class_property"]._original_tsp_name = "class" - self._backcompat_attr_to_rest_field = { - "type": self._attr_to_rest_field["type_property"], - "class": self._attr_to_rest_field["class_property"], - } + values_property: str = rest_field(name="valuesWire", original_tsp_name="values") + get_property: str = rest_field(name="getWire", original_tsp_name="get") class FlattenedPropsBackcompatModel(HybridModel): name: str = rest_field() properties: BackcompatContentModel = rest_field() - __flattened_items = ["type", "class"] # Use original names + __flattened_items = ["values_property", "get_property"] - def __init__(self, *args, **kwargs): - _flattened = {} - for item in ["type", "class"]: - if item in kwargs: - _flattened[item] = kwargs.pop(item) + def __init__(self, *args: Any, **kwargs: Any) -> None: + _flattened_input = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} super().__init__(*args, **kwargs) - for k, v in _flattened.items(): + for k, v in _flattened_input.items(): setattr(self, k, v) - def __getattr__(self, name: str): - if name in self.__flattened_items and self.properties is not None: - attr_map = {"type": "type_property", "class": "class_property"} - return getattr(self.properties, attr_map[name]) - raise AttributeError(f"No attribute '{name}'") + def __getattr__(self, name: str) -> Any: + if name in self.__flattened_items: + if self.properties is None: + return None + return getattr(self.properties, name) + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - def __setattr__(self, key: str, value): + def __setattr__(self, key: str, value: Any) -> None: if key in self.__flattened_items: if self.properties is None: - self.properties = BackcompatContentModel() - attr_map = {"type": "type_property", "class": "class_property"} - setattr(self.properties, attr_map[key], value) + self.properties = self._attr_to_rest_field["properties"]._class_type() + setattr(self.properties, key, value) else: super().__setattr__(key, value) - model = FlattenedPropsBackcompatModel(name="test_name", type="test_type", **{"class": "test_class"}) + model = FlattenedPropsBackcompatModel( + name="test_name", values_property="test_values", get_property="test_class" + ) # Should use original names for flattened properties attrs = attribute_list(model) - assert set(attrs) == {"name", "type", "class"} + assert set(attrs) == {"name", "values", "get"} + assert get_old_attribute(model, "values_property") == "test_values" + assert "test_name" in model.values() attr_dict = as_attribute_dict(model) - expected = {"name": "test_name", "type": "test_type", "class": "test_class"} + expected = {"name": "test_name", "values": "test_values", "get": "test_class"} assert attr_dict == expected def test_6c_flattened_with_readonly_exclusion(self): """Flattened model with readonly properties and exclude_readonly behavior""" class ReadonlyContentModel(HybridModel): - readonly_field: str = rest_field( - name="readonlyWire", visibility=["read"], original_tsp_name="readonly_prop" - ) - readwrite_field: str = rest_field(name="readwriteWire", original_tsp_name="readwrite_prop") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._attr_to_rest_field["readonly_field"]._original_tsp_name = "readonly_prop" - self._attr_to_rest_field["readwrite_field"]._original_tsp_name = "readwrite_prop" - self._backcompat_attr_to_rest_field = { - "readonly_prop": self._attr_to_rest_field["readonly_field"], - "readwrite_prop": self._attr_to_rest_field["readwrite_field"], - } + setdefault_property: str = rest_field(name="readonlyWire", original_tsp_name="setdefault") + popitem_property: str = rest_field(name="readwriteWire", original_tsp_name="popitem") class FlattenedReadonlyModel(HybridModel): - id: str = rest_field() - content: ReadonlyContentModel = rest_field() + get_property: str = rest_field(name="getProperty", original_tsp_name="get", visibility=["read"]) + properties: ReadonlyContentModel = rest_field() - __flattened_items = ["readonly_prop", "readwrite_prop"] + __flattened_items = ["setdefault_property", "popitem_property"] - def __init__(self, *args, **kwargs): - _flattened = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} + def __init__(self, *args: Any, **kwargs: Any) -> None: + _flattened_input = {k: kwargs.pop(k) for k in kwargs.keys() & self.__flattened_items} super().__init__(*args, **kwargs) - for k, v in _flattened.items(): + for k, v in _flattened_input.items(): setattr(self, k, v) - def __getattr__(self, name: str): - if name in self.__flattened_items and self.content is not None: - attr_map = {"readonly_prop": "readonly_field", "readwrite_prop": "readwrite_field"} - return getattr(self.content, attr_map[name]) - raise AttributeError(f"No attribute '{name}'") + def __getattr__(self, name: str) -> Any: + if name in self.__flattened_items: + if self.properties is None: + return None + return getattr(self.properties, name) + raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") - def __setattr__(self, key: str, value): + def __setattr__(self, key: str, value: Any) -> None: if key in self.__flattened_items: - if self.content is None: - self.content = ReadonlyContentModel() - attr_map = {"readonly_prop": "readonly_field", "readwrite_prop": "readwrite_field"} - setattr(self.content, attr_map[key], value) + if self.properties is None: + self.properties = self._attr_to_rest_field["properties"]._class_type() + setattr(self.properties, key, value) else: super().__setattr__(key, value) - model = FlattenedReadonlyModel(id="test_id", readonly_prop="readonly_value", readwrite_prop="readwrite_value") + model = FlattenedReadonlyModel( + get_property="test_get", setdefault_property="setdefault", popitem_property="readwrite_value" + ) # All properties included by default full_dict = as_attribute_dict(model, exclude_readonly=False) - expected_full = {"id": "test_id", "readonly_prop": "readonly_value", "readwrite_prop": "readwrite_value"} + expected_full = {"get": "test_get", "setdefault": "setdefault", "popitem": "readwrite_value"} assert full_dict == expected_full # Readonly properties excluded when requested filtered_dict = as_attribute_dict(model, exclude_readonly=True) - expected_filtered = {"id": "test_id", "readwrite_prop": "readwrite_value"} + expected_filtered = {"setdefault": "setdefault", "popitem": "readwrite_value"} assert filtered_dict == expected_filtered + attribute_list_result = attribute_list(model) + expected_attrs = {"get", "setdefault", "popitem"} + assert set(attribute_list_result) == expected_attrs + assert get_old_attribute(model, "setdefault") == "setdefault" + assert get_old_attribute(model, "popitem") == "readwrite_value" + assert getattr(model, "get_property") == "test_get" + # ========== EDGE CASES ========== def test_mixed_combinations_comprehensive(self): @@ -1994,23 +2020,28 @@ class ComprehensiveModel(HybridModel): keys_property: str = rest_field(original_tsp_name="keys") # Case 4: Padded field with original, different wire name - type_property: str = rest_field(name="typeWire", original_tsp_name="type") + values_property: str = rest_field(name="valuesWire", original_tsp_name="values") # Case 5: Readonly field with original - readonly_class: str = rest_field(name="classWire", visibility=["read"], original_tsp_name="class") + items_property: str = rest_field(name="itemsWire", visibility=["read"], original_tsp_name="items") model = ComprehensiveModel( normal_field="normal", different_wire="different", keys_property="keys_val", - type_property="type_val", - readonly_class="class_val", + values_property="values_val", + items_property="items_val", ) # attribute_list should use backcompat names where available attrs = attribute_list(model) - expected_attrs = {"normal_field", "different_wire", "keys", "type", "class"} + expected_attrs = {"normal_field", "different_wire", "keys", "values", "items"} assert set(attrs) == expected_attrs + assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "keys_val" + assert getattr(model, "values_property") == get_old_attribute(model, "values") == "values_val" + assert getattr(model, "items_property") == get_old_attribute(model, "items") == "items_val" + assert getattr(model, "normal_field") == get_old_attribute(model, "normal_field") == "normal" + assert getattr(model, "different_wire") == get_old_attribute(model, "different_wire") == "different" # Full as_attribute_dict full_dict = as_attribute_dict(model) @@ -2018,8 +2049,8 @@ class ComprehensiveModel(HybridModel): "normal_field": "normal", "different_wire": "different", "keys": "keys_val", - "type": "type_val", - "class": "class_val", + "values": "values_val", + "items": "items_val", } assert full_dict == expected_full @@ -2029,8 +2060,8 @@ class ComprehensiveModel(HybridModel): "normal_field": "normal", "different_wire": "different", "keys": "keys_val", - "type": "type_val", - # "class" excluded because it's readonly + "values": "values_val", + # "items" excluded because it's readonly } assert filtered_dict == expected_filtered @@ -2040,8 +2071,8 @@ class ComprehensiveModel(HybridModel): "normal_field": "normal", # same as attr "wireNameDifferent": "different", # different wire name "keys_property": "keys_val", # same as attr (padded) - "typeWire": "type_val", # different wire name - "classWire": "class_val", # different wire name + "valuesWire": "values_val", # different wire name + "itemsWire": "items_val", # different wire name } assert wire_dict == expected_wire @@ -2070,3 +2101,4 @@ class PaddingInNameModel(HybridModel): assert attribute_list(model) == ["keys_property"] assert as_attribute_dict(model) == {"keys_property": "value"} assert dict(model) == {"myKeys": "value"} + assert getattr(model, "keys_property") == "value" From b223bbc8915f2656d41948a5dae3483c8d3b2f7d Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Wed, 3 Dec 2025 20:04:11 -0500 Subject: [PATCH 06/11] switch to get_backcompat_attr_name --- sdk/core/azure-core/CHANGELOG.md | 8 +- .../azure-core/azure/core/serialization.py | 53 ++++++------- .../azure-core/tests/test_serialization.py | 74 ++++++++++--------- 3 files changed, 70 insertions(+), 65 deletions(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index a5b08ea4f0b1..fa7d74214ee6 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,14 +1,12 @@ # Release History -## 1.36.1 (Unreleased) +## 1.37.0(Unreleased) ### Features Added -### Breaking Changes - -### Bugs Fixed +- Added `get_backcompat_attr` to `azure.core.serialization`. `get_backcompat_attr` gets the value of an attribute using backcompat attribute access. #44084 -- Fix `attribute_list` and `as_attribute_dict` to return original model attribute name in cases where we pad the name now but used to not pad #44084 +### Breaking Changes ### Other Changes diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index fa080927f6ce..fbb81dc3473f 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -20,7 +20,7 @@ "as_attribute_dict", "attribute_list", "TypeHandlerRegistry", - "get_old_attribute", + "get_backcompat_attr_name", ] TZ_UTC = timezone.utc @@ -393,7 +393,7 @@ def attribute_list(obj: Any) -> List[str]: if flattened_attribute == attr_name: retval.extend(attribute_list(rest_field._class_type)) else: - retval.append(_get_backcompat_name(rest_field, attr_name)) + retval.append(attr_name) return retval @@ -431,9 +431,9 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, readonly_props.add(rest_field._rest_name) if flattened_attribute == attr_name: for fk, fv in rest_field._class_type._attr_to_rest_field.items(): - rest_to_attr[fv._rest_name] = _get_backcompat_name(fv, fk) + rest_to_attr[fv._rest_name] = fk else: - rest_to_attr[rest_field._rest_name] = _get_backcompat_name(rest_field, attr_name) + rest_to_attr[rest_field._rest_name] = attr_name for k, v in obj.items(): if exclude_readonly and k in readonly_props: # pyright: ignore continue @@ -458,33 +458,34 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, raise TypeError("Object must be a generated model instance.") from exc -def get_old_attribute(model: Any, field_name: str) -> Any: - """Get the value of an attribute using backcompat attribute access. +def get_backcompat_attr_name(model: Any, attr_name: str) -> str: + """Get the backcompat attribute name for a given attribute. - This function takes a field name that may be a backcompat name (original TSP name) - and returns the value from the corresponding actual attribute on the model. + This function takes an attribute name and returns the backcompat name (original TSP name) + if one exists, otherwise returns the attribute name itself. :param any model: The model instance. - :param str field_name: The backcompat attribute name (from attribute_list). - :return: The value of the attribute. - :rtype: any + :param str attr_name: The attribute name to get the backcompat name for. + :return: The backcompat attribute name (original TSP name) or the attribute name itself. + :rtype: str """ if not is_generated_model(model): raise TypeError("Object must be a generated model instance.") - - # Check if field_name is an original TSP name in the model + + # Check if attr_name exists in the model's attributes flattened_attribute = _get_flattened_attribute(model) - for attr_name, rest_field in model._attr_to_rest_field.items(): - # Check if field_name matches this attribute's original TSP name (regardless of flattening) - if _get_backcompat_name(rest_field, attr_name) == field_name: - return getattr(model, attr_name) - - # If this is a flattened attribute, check if field_name is an original TSP name inside it - if flattened_attribute == attr_name: + for field_attr_name, rest_field in model._attr_to_rest_field.items(): + # Check if this is the attribute we're looking for + if field_attr_name == attr_name: + # Return the original TSP name if it exists, otherwise the attribute name + return _get_backcompat_name(rest_field, attr_name) + + # If this is a flattened attribute, check inside it + if flattened_attribute == field_attr_name: for fk, fv in rest_field._class_type._attr_to_rest_field.items(): - if _get_backcompat_name(fv, fk) == field_name: - # This is a flattened property - access the actual attribute name - return getattr(model, fk) - - # Fallback to direct attribute access - return getattr(model, field_name) + if fk == attr_name: + # Return the original TSP name for this flattened property + return _get_backcompat_name(fv, fk) + + # If not found in the model, just return the attribute name as-is + return attr_name diff --git a/sdk/core/azure-core/tests/test_serialization.py b/sdk/core/azure-core/tests/test_serialization.py index a59c5fabf0ba..fb386cf130c3 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -7,7 +7,6 @@ from enum import Enum import json import sys -import traceback from typing import Any, Dict, List, Optional, Union, Type from io import BytesIO @@ -15,7 +14,7 @@ AzureJSONEncoder, NULL, as_attribute_dict, - get_old_attribute, + get_backcompat_attr_name, is_generated_model, attribute_list, ) @@ -1698,7 +1697,7 @@ class RegularModel(HybridModel): assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {"field_name": "value"} assert getattr(model, "field_name") == "value" - assert get_old_attribute(model, "field_name") == "value" + assert get_backcompat_attr_name(model, "field_name") == "field_name" def test_1b_same_wire_normal_attr_no_original_readonly_regular(self): """Wire=attr, normal attr, no original, readonly, regular model""" @@ -1713,7 +1712,7 @@ class ReadonlyModel(HybridModel): assert as_attribute_dict(model) == {"field_name": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} assert getattr(model, "field_name") == "value" - assert get_old_attribute(model, "field_name") == "value" + assert get_backcompat_attr_name(model, "field_name") == "field_name" def test_2a_different_wire_normal_attr_no_original_readwrite_regular(self): """Wire≠attr, normal attr, no original, readwrite, regular model""" @@ -1729,7 +1728,7 @@ class DifferentWireModel(HybridModel): # Verify wire representation uses different name assert dict(model) == {"wireField": "value"} assert getattr(model, "client_field") == "value" - assert get_old_attribute(model, "client_field") == "value" + assert get_backcompat_attr_name(model, "client_field") == "client_field" def test_2b_different_wire_normal_attr_no_original_readonly_regular(self): """Wire≠attr, normal attr, no original, readonly, regular model""" @@ -1744,7 +1743,7 @@ class ReadonlyDifferentWireModel(HybridModel): assert as_attribute_dict(model) == {"client_field": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} assert getattr(model, "client_field") == "value" - assert get_old_attribute(model, "client_field") == "value" + assert get_backcompat_attr_name(model, "client_field") == "client_field" def test_3a_same_wire_padded_attr_with_original_readwrite_regular(self): """Wire=original, padded attr, original present, readwrite, regular model""" @@ -1755,9 +1754,10 @@ class PaddedModel(HybridModel): model = PaddedModel(keys_property="value") # Should use original_tsp_name when available - assert attribute_list(model) == ["keys"] - assert as_attribute_dict(model) == {"keys": "value"} - assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "value" + assert attribute_list(model) == ["keys_property"] + assert as_attribute_dict(model) == {"keys_property": "value"} + assert get_backcompat_attr_name(model, "keys_property") == "keys" + assert getattr(model, "keys_property") == "value" assert set(model.keys()) == {"keys_property"} def test_3b_same_wire_padded_attr_with_original_readonly_regular(self): @@ -1769,10 +1769,11 @@ class ReadonlyPaddedModel(HybridModel): model = ReadonlyPaddedModel(keys_property="value") # Should use original_tsp_name, excluded when exclude_readonly=True - assert attribute_list(model) == ["keys"] - assert as_attribute_dict(model) == {"keys": "value"} + assert attribute_list(model) == ["keys_property"] + assert as_attribute_dict(model) == {"keys_property": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "value" + assert get_backcompat_attr_name(model, "keys_property") == "keys" + assert getattr(model, "keys_property") == "value" assert set(model.keys()) == {"keys_property"} def test_4a_different_wire_padded_attr_with_original_readwrite_regular(self): @@ -1788,7 +1789,7 @@ class DifferentWirePaddedModel(HybridModel): assert as_attribute_dict(model) == {"clear": "value"} # Verify wire uses different name assert dict(model) == {"clearWire": "value"} - assert getattr(model, "clear_property") == get_old_attribute(model, "clear") == "value" + assert getattr(model, "clear_property") == get_backcompat_attr_name(model, "clear") == "value" assert set(model.keys()) == {"clearWire"} def test_4b_different_wire_padded_attr_with_original_readonly_regular(self): @@ -1803,7 +1804,7 @@ class ReadonlyDifferentWirePaddedModel(HybridModel): assert attribute_list(model) == ["pop"] assert as_attribute_dict(model) == {"pop": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - assert getattr(model, "pop_property") == get_old_attribute(model, "pop") == "value" + assert getattr(model, "pop_property") == get_backcompat_attr_name(model, "pop") == "value" assert set(model.keys()) == {"popWire"} # ========== DIMENSION 5: STRUCTURE VARIATIONS ========== @@ -1837,8 +1838,8 @@ class ParentModel(HybridModel): expected_parent = {"nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, "items": "parent_items"} assert parent_dict == expected_parent - assert getattr(nested_model, "keys_property") == get_old_attribute(nested_model, "keys") == "nested_keys" - assert getattr(parent_model, "items_property") == get_old_attribute(parent_model, "items") == "parent_items" + assert getattr(nested_model, "keys_property") == get_backcompat_attr_name(nested_model, "keys") == "nested_keys" + assert getattr(parent_model, "items_property") == get_backcompat_attr_name(parent_model, "items") == "parent_items" assert set(nested_model.keys()) == {"keysWire", "normalWire"} assert set(nested_model.items()) == {("keysWire", "nested_keys"), ("normalWire", "nested_normal")} @@ -1895,9 +1896,9 @@ def __setattr__(self, key: str, value: Any) -> None: expected = {"id": "test_id", "name": "flattened_name", "description": "flattened_desc"} assert attr_dict == expected - assert get_old_attribute(model, "update") is model.update_property - assert get_old_attribute(model, "update").name == "flattened_name" - assert get_old_attribute(model, "update").description == "flattened_desc" + assert get_backcompat_attr_name(model, "update") is model.update_property + assert get_backcompat_attr_name(model, "update").name == "flattened_name" + assert get_backcompat_attr_name(model, "update").description == "flattened_desc" assert set(model.keys()) == {"id", "update_property"} @@ -1942,7 +1943,7 @@ def __setattr__(self, key: str, value: Any) -> None: # Should use original names for flattened properties attrs = attribute_list(model) assert set(attrs) == {"name", "values", "get"} - assert get_old_attribute(model, "values_property") == "test_values" + assert get_backcompat_attr_name(model, "values_property") == "test_values" assert "test_name" in model.values() attr_dict = as_attribute_dict(model) @@ -2000,8 +2001,8 @@ def __setattr__(self, key: str, value: Any) -> None: attribute_list_result = attribute_list(model) expected_attrs = {"get", "setdefault", "popitem"} assert set(attribute_list_result) == expected_attrs - assert get_old_attribute(model, "setdefault") == "setdefault" - assert get_old_attribute(model, "popitem") == "readwrite_value" + assert get_backcompat_attr_name(model, "setdefault") == "setdefault" + assert get_backcompat_attr_name(model, "popitem") == "readwrite_value" assert getattr(model, "get_property") == "test_get" # ========== EDGE CASES ========== @@ -2035,22 +2036,27 @@ class ComprehensiveModel(HybridModel): # attribute_list should use backcompat names where available attrs = attribute_list(model) - expected_attrs = {"normal_field", "different_wire", "keys", "values", "items"} + expected_attrs = {"normal_field", "different_wire", "keys_property", "values_property", "items_property"} assert set(attrs) == expected_attrs - assert getattr(model, "keys_property") == get_old_attribute(model, "keys") == "keys_val" - assert getattr(model, "values_property") == get_old_attribute(model, "values") == "values_val" - assert getattr(model, "items_property") == get_old_attribute(model, "items") == "items_val" - assert getattr(model, "normal_field") == get_old_attribute(model, "normal_field") == "normal" - assert getattr(model, "different_wire") == get_old_attribute(model, "different_wire") == "different" + assert get_backcompat_attr_name(model, "keys_property") == "keys" + assert get_backcompat_attr_name(model, "values_property") == "values" + assert get_backcompat_attr_name(model, "items_property") == "items" + assert get_backcompat_attr_name(model, "normal_field") == "normal_field" + assert get_backcompat_attr_name(model, "different_wire") == "different_wire" + assert getattr(model, "keys_property") == "keys_val" + assert getattr(model, "values_property") == "values_val" + assert getattr(model, "items_property") == "items_val" + assert getattr(model, "normal_field") == "normal" + assert getattr(model, "different_wire") == "different" # Full as_attribute_dict full_dict = as_attribute_dict(model) expected_full = { "normal_field": "normal", "different_wire": "different", - "keys": "keys_val", - "values": "values_val", - "items": "items_val", + "keys_property": "keys_val", + "values_property": "values_val", + "items_property": "items_val", } assert full_dict == expected_full @@ -2059,9 +2065,9 @@ class ComprehensiveModel(HybridModel): expected_filtered = { "normal_field": "normal", "different_wire": "different", - "keys": "keys_val", - "values": "values_val", - # "items" excluded because it's readonly + "keys_property": "keys_val", + "values_property": "values_val", + # "items_property" excluded because it's readonly } assert filtered_dict == expected_filtered From a7df72e18e04d85e7ef6135f7af025bac35d4bfe Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 4 Dec 2025 13:26:01 +0800 Subject: [PATCH 07/11] fix ci --- sdk/core/azure-core/CHANGELOG.md | 2 +- sdk/core/azure-core/azure/core/_version.py | 2 +- .../azure-core/tests/test_serialization.py | 56 ++++++++++--------- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index fa7d74214ee6..8a1af2cbb5bb 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 1.37.0(Unreleased) +## 1.37.0 (Unreleased) ### Features Added diff --git a/sdk/core/azure-core/azure/core/_version.py b/sdk/core/azure-core/azure/core/_version.py index 4ac212ba998c..5b21ec1bb8d4 100644 --- a/sdk/core/azure-core/azure/core/_version.py +++ b/sdk/core/azure-core/azure/core/_version.py @@ -9,4 +9,4 @@ # regenerated. # -------------------------------------------------------------------------- -VERSION = "1.36.1" +VERSION = "1.37.0" diff --git a/sdk/core/azure-core/tests/test_serialization.py b/sdk/core/azure-core/tests/test_serialization.py index fb386cf130c3..10fe1b7d61b1 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -1768,12 +1768,11 @@ class ReadonlyPaddedModel(HybridModel): model = ReadonlyPaddedModel(keys_property="value") - # Should use original_tsp_name, excluded when exclude_readonly=True assert attribute_list(model) == ["keys_property"] assert as_attribute_dict(model) == {"keys_property": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} assert get_backcompat_attr_name(model, "keys_property") == "keys" - assert getattr(model, "keys_property") == "value" + assert getattr(model, "keys_property") == "value" assert set(model.keys()) == {"keys_property"} def test_4a_different_wire_padded_attr_with_original_readwrite_regular(self): @@ -1784,12 +1783,11 @@ class DifferentWirePaddedModel(HybridModel): model = DifferentWirePaddedModel(clear_property="value") - # Should use original_tsp_name - assert attribute_list(model) == ["clear"] - assert as_attribute_dict(model) == {"clear": "value"} + assert attribute_list(model) == ["clear_property"] + assert as_attribute_dict(model) == {"clear_property": "value"} # Verify wire uses different name assert dict(model) == {"clearWire": "value"} - assert getattr(model, "clear_property") == get_backcompat_attr_name(model, "clear") == "value" + assert getattr(model, "clear_property") == "value" assert set(model.keys()) == {"clearWire"} def test_4b_different_wire_padded_attr_with_original_readonly_regular(self): @@ -1800,11 +1798,10 @@ class ReadonlyDifferentWirePaddedModel(HybridModel): model = ReadonlyDifferentWirePaddedModel(pop_property="value") - # Should use original_tsp_name, excluded when exclude_readonly=True - assert attribute_list(model) == ["pop"] - assert as_attribute_dict(model) == {"pop": "value"} + assert attribute_list(model) == ["pop_property"] + assert as_attribute_dict(model) == {"pop_property": "value"} assert as_attribute_dict(model, exclude_readonly=True) == {} - assert getattr(model, "pop_property") == get_backcompat_attr_name(model, "pop") == "value" + assert getattr(model, "pop_property") == "value" assert set(model.keys()) == {"popWire"} # ========== DIMENSION 5: STRUCTURE VARIATIONS ========== @@ -1825,21 +1822,24 @@ class ParentModel(HybridModel): # Test nested model independently nested_attrs = attribute_list(nested_model) - assert set(nested_attrs) == {"keys", "normal_field"} + assert set(nested_attrs) == {"keys_property", "normal_field"} nested_dict = as_attribute_dict(nested_model) - assert nested_dict == {"keys": "nested_keys", "normal_field": "nested_normal"} + assert nested_dict == {"keys_property": "nested_keys", "normal_field": "nested_normal"} # Test parent model with recursive backcompat parent_attrs = attribute_list(parent_model) - assert set(parent_attrs) == {"nested", "items"} + assert set(parent_attrs) == {"nested", "items_property"} parent_dict = as_attribute_dict(parent_model) - expected_parent = {"nested": {"keys": "nested_keys", "normal_field": "nested_normal"}, "items": "parent_items"} + expected_parent = { + "nested": {"keys_property": "nested_keys", "normal_field": "nested_normal"}, + "items_property": "parent_items", + } assert parent_dict == expected_parent - assert getattr(nested_model, "keys_property") == get_backcompat_attr_name(nested_model, "keys") == "nested_keys" - assert getattr(parent_model, "items_property") == get_backcompat_attr_name(parent_model, "items") == "parent_items" + assert getattr(nested_model, "keys_property") == "nested_keys" + assert getattr(parent_model, "items_property") == "parent_items" assert set(nested_model.keys()) == {"keysWire", "normalWire"} assert set(nested_model.items()) == {("keysWire", "nested_keys"), ("normalWire", "nested_normal")} @@ -1896,9 +1896,7 @@ def __setattr__(self, key: str, value: Any) -> None: expected = {"id": "test_id", "name": "flattened_name", "description": "flattened_desc"} assert attr_dict == expected - assert get_backcompat_attr_name(model, "update") is model.update_property - assert get_backcompat_attr_name(model, "update").name == "flattened_name" - assert get_backcompat_attr_name(model, "update").description == "flattened_desc" + assert get_backcompat_attr_name(model, "update_property") == "update" assert set(model.keys()) == {"id", "update_property"} @@ -1942,12 +1940,12 @@ def __setattr__(self, key: str, value: Any) -> None: # Should use original names for flattened properties attrs = attribute_list(model) - assert set(attrs) == {"name", "values", "get"} - assert get_backcompat_attr_name(model, "values_property") == "test_values" + assert set(attrs) == {"name", "values_property", "get_property"} + assert get_backcompat_attr_name(model, "values_property") == "values" assert "test_name" in model.values() attr_dict = as_attribute_dict(model) - expected = {"name": "test_name", "values": "test_values", "get": "test_class"} + expected = {"name": "test_name", "values_property": "test_values", "get_property": "test_class"} assert attr_dict == expected def test_6c_flattened_with_readonly_exclusion(self): @@ -1990,19 +1988,23 @@ def __setattr__(self, key: str, value: Any) -> None: # All properties included by default full_dict = as_attribute_dict(model, exclude_readonly=False) - expected_full = {"get": "test_get", "setdefault": "setdefault", "popitem": "readwrite_value"} + expected_full = { + "get_property": "test_get", + "setdefault_property": "setdefault", + "popitem_property": "readwrite_value", + } assert full_dict == expected_full # Readonly properties excluded when requested filtered_dict = as_attribute_dict(model, exclude_readonly=True) - expected_filtered = {"setdefault": "setdefault", "popitem": "readwrite_value"} + expected_filtered = {"setdefault_property": "setdefault", "popitem_property": "readwrite_value"} assert filtered_dict == expected_filtered attribute_list_result = attribute_list(model) - expected_attrs = {"get", "setdefault", "popitem"} + expected_attrs = {"get_property", "setdefault_property", "popitem_property"} assert set(attribute_list_result) == expected_attrs - assert get_backcompat_attr_name(model, "setdefault") == "setdefault" - assert get_backcompat_attr_name(model, "popitem") == "readwrite_value" + assert get_backcompat_attr_name(model, "setdefault_property") == "setdefault" + assert get_backcompat_attr_name(model, "popitem_property") == "popitem" assert getattr(model, "get_property") == "test_get" # ========== EDGE CASES ========== From 16793a8aa1a2bfcf74b2311d94b21735163f7836 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 4 Dec 2025 14:39:08 +0800 Subject: [PATCH 08/11] format --- sdk/core/azure-core/azure/core/serialization.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/core/azure-core/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index fbb81dc3473f..65f2608c18d6 100644 --- a/sdk/core/azure-core/azure/core/serialization.py +++ b/sdk/core/azure-core/azure/core/serialization.py @@ -471,7 +471,7 @@ def get_backcompat_attr_name(model: Any, attr_name: str) -> str: """ if not is_generated_model(model): raise TypeError("Object must be a generated model instance.") - + # Check if attr_name exists in the model's attributes flattened_attribute = _get_flattened_attribute(model) for field_attr_name, rest_field in model._attr_to_rest_field.items(): @@ -479,13 +479,13 @@ def get_backcompat_attr_name(model: Any, attr_name: str) -> str: if field_attr_name == attr_name: # Return the original TSP name if it exists, otherwise the attribute name return _get_backcompat_name(rest_field, attr_name) - + # If this is a flattened attribute, check inside it if flattened_attribute == field_attr_name: for fk, fv in rest_field._class_type._attr_to_rest_field.items(): if fk == attr_name: # Return the original TSP name for this flattened property return _get_backcompat_name(fv, fk) - + # If not found in the model, just return the attribute name as-is return attr_name From 9d3534353fb94174aa82662ce134a147af788575 Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Sat, 6 Dec 2025 18:32:50 -0500 Subject: [PATCH 09/11] update changelog --- sdk/core/azure-core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 8a1af2cbb5bb..56b8834c3f5d 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Added `get_backcompat_attr` to `azure.core.serialization`. `get_backcompat_attr` gets the value of an attribute using backcompat attribute access. #44084 +- Added `get_backcompat_attr_name` to `azure.core.serialization`. `get_backcompat_attr_name` gets the backcompat name of an attribute using backcompat attribute access. #44084 ### Breaking Changes From 533ef4fb375a3d5bbd378f71bce7248e2311b6aa Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Mon, 8 Dec 2025 14:25:18 +0800 Subject: [PATCH 10/11] Update sdk/core/azure-core/CHANGELOG.md --- sdk/core/azure-core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 56b8834c3f5d..d97164f5151e 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,6 +1,6 @@ # Release History -## 1.37.0 (Unreleased) +## 1.37.0 (2025-12-08) ### Features Added From 06cb23988298fb1e170db3dce5a9b0841283321b Mon Sep 17 00:00:00 2001 From: iscai-msft Date: Mon, 8 Dec 2025 12:17:26 -0500 Subject: [PATCH 11/11] fix changelog analyze --- sdk/core/azure-core/CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index d97164f5151e..7c2b2b090918 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -6,8 +6,6 @@ - Added `get_backcompat_attr_name` to `azure.core.serialization`. `get_backcompat_attr_name` gets the backcompat name of an attribute using backcompat attribute access. #44084 -### Breaking Changes - ### Other Changes - Updated `BearerTokenCredentialPolicy` and `AsyncBearerTokenCredentialPolicy` to set the `enable_cae` parameter to `True` by default. This change enables Continuous Access Evaluation (CAE) for all token requests made through these policies. #42941