diff --git a/sdk/core/azure-core/CHANGELOG.md b/sdk/core/azure-core/CHANGELOG.md index 160b820c1a97..7c2b2b090918 100644 --- a/sdk/core/azure-core/CHANGELOG.md +++ b/sdk/core/azure-core/CHANGELOG.md @@ -1,12 +1,10 @@ # Release History -## 1.36.1 (Unreleased) +## 1.37.0 (2025-12-08) ### Features Added -### Breaking Changes - -### Bugs Fixed +- 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 ### Other Changes 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/azure/core/serialization.py b/sdk/core/azure-core/azure/core/serialization.py index df6db58319e0..65f2608c18d6 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_backcompat_attr_name", ] 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,6 +334,20 @@ 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_name(rest_field: Any, default_attr_name: str) -> str: + """Get the backcompat name for an attribute. + + :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 rest_field._original_tsp_name or default_attr_name + except AttributeError: + return default_attr_name + + 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 +364,9 @@ 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 + for k, v in obj._attr_to_rest_field.items(): try: - if set(v._class_type._attr_to_rest_field.keys()).intersection( # pylint: disable=protected-access - set(flattened_items) - ): + 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 @@ -372,12 +386,12 @@ 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, rest_field in obj._attr_to_rest_field.items(): # pylint: disable=protected-access + for attr_name, rest_field in obj._attr_to_rest_field.items(): if flattened_attribute == attr_name: - retval.extend(attribute_list(rest_field._class_type)) # pylint: disable=protected-access + retval.extend(attribute_list(rest_field._class_type)) else: retval.append(attr_name) return retval @@ -410,16 +424,16 @@ 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 + 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 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 fk, fv in rest_field._class_type._attr_to_rest_field.items(): + rest_to_attr[fv._rest_name] = fk else: - rest_to_attr[rest_field._rest_name] = attr_name # pylint: disable=protected-access + 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 @@ -429,10 +443,8 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, 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 - 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 @@ -444,3 +456,36 @@ def as_attribute_dict(obj: Any, *, exclude_readonly: bool = False) -> Dict[str, except AttributeError as exc: # not a typespec generated model raise TypeError("Object must be a generated model instance.") from exc + + +def get_backcompat_attr_name(model: Any, attr_name: str) -> str: + """Get the backcompat attribute name for a given attribute. + + 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 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 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(): + # 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 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/specs_sdk/modeltypes/modeltypes/_utils/model_base.py b/sdk/core/azure-core/tests/specs_sdk/modeltypes/modeltypes/_utils/model_base.py index e46c56097a8c..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 @@ -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,16 @@ 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 +1012,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 +1024,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 +1076,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 +1086,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..10fe1b7d61b1 100644 --- a/sdk/core/azure-core/tests/test_serialization.py +++ b/sdk/core/azure-core/tests/test_serialization.py @@ -7,11 +7,17 @@ from enum import Enum import json import sys -import traceback 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_backcompat_attr_name, + is_generated_model, + attribute_list, +) from azure.core.exceptions import DeserializationError import pytest from modeltypes._utils.model_base import ( @@ -1644,3 +1650,463 @@ 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() + + 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"} + assert getattr(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""" + + 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) == {} + assert getattr(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""" + + class DifferentWireModel(HybridModel): + client_field: str = rest_field(name="wireField") + + 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"} + assert getattr(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""" + + 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) == {} + assert getattr(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""" + + class PaddedModel(HybridModel): + 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_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): + """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") + + 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 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): + clear_property: str = rest_field(name="clearWire", original_tsp_name="clear") + + model = DifferentWirePaddedModel(clear_property="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") == "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): + pop_property: str = rest_field(name="popWire", visibility=["read"], original_tsp_name="pop") + + model = ReadonlyDifferentWirePaddedModel(pop_property="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") == "value" + assert set(model.keys()) == {"popWire"} + + # ========== 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() + 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, items_property="parent_items") + + # Test nested model independently + nested_attrs = attribute_list(nested_model) + assert set(nested_attrs) == {"keys_property", "normal_field"} + + nested_dict = as_attribute_dict(nested_model) + 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_property"} + + parent_dict = as_attribute_dict(parent_model) + 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") == "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")} + 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)""" + + # Helper model for flattening content + class ContentModel(HybridModel): + name: str = rest_field() + description: str = rest_field() + + class FlattenedContainerModel(HybridModel): + id: str = rest_field() + update_property: ContentModel = rest_field(original_tsp_name="update") + + __flattened_items = ["name", "description"] + + 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_input.items(): + setattr(self, k, v) + + 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: Any) -> None: + if key in self.__flattened_items: + 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) + + 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"} + 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_backcompat_attr_name(model, "update_property") == "update" + + 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): + 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 = ["values_property", "get_property"] + + 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_input.items(): + setattr(self, k, v) + + 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: Any) -> None: + if key in self.__flattened_items: + 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 = 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", "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_property": "test_values", "get_property": "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): + 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): + get_property: str = rest_field(name="getProperty", original_tsp_name="get", visibility=["read"]) + properties: ReadonlyContentModel = rest_field() + + __flattened_items = ["setdefault_property", "popitem_property"] + + 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_input.items(): + setattr(self, k, v) + + 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: Any) -> None: + if key in self.__flattened_items: + 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( + 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 = { + "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_property": "setdefault", "popitem_property": "readwrite_value"} + assert filtered_dict == expected_filtered + + attribute_list_result = attribute_list(model) + expected_attrs = {"get_property", "setdefault_property", "popitem_property"} + assert set(attribute_list_result) == expected_attrs + 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 ========== + + 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 + values_property: str = rest_field(name="valuesWire", original_tsp_name="values") + + # Case 5: Readonly field with original + 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", + 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_property", "values_property", "items_property"} + assert set(attrs) == expected_attrs + 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_property": "keys_val", + "values_property": "values_val", + "items_property": "items_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_property": "keys_val", + "values_property": "values_val", + # "items_property" 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) + "valuesWire": "values_val", # different wire name + "itemsWire": "items_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"} + assert getattr(model, "keys_property") == "value"