From d5ea4dcf2d13473eaed409e29f644a2a68252a06 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Tue, 18 Nov 2025 22:12:36 -0500 Subject: [PATCH] Add alias support to Property class and related tests - Introduced `alias_of` parameter in Property to allow aliasing of API attributes. - Implemented `properties_with_alias` method in Base class to retrieve aliased properties. - Updated BetaProgram to include an aliased property for "class". - Added comprehensive tests for alias functionality in PropertyAliasTest. --- linode_api4/objects/base.py | 158 ++++++++++++------- linode_api4/objects/beta.py | 1 + test/unit/objects/property_alias_test.py | 191 +++++++++++++++++++++++ 3 files changed, 291 insertions(+), 59 deletions(-) create mode 100644 test/unit/objects/property_alias_test.py diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 9f2a55589..4c49bc3ed 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,5 +1,7 @@ import time from datetime import datetime, timedelta +from functools import cached_property +from itertools import chain from typing import Any, Dict, Optional from linode_api4.objects.serializable import JSONObject @@ -35,27 +37,43 @@ def __init__( nullable=False, unordered=False, json_object=None, + alias_of: Optional[str] = None, ): """ A Property is an attribute returned from the API, and defines metadata - about that value. These are expected to be used as the values of a + about that value. These are expected to be used as the values of a class-level dict named 'properties' in subclasses of Base. - mutable - This Property should be sent in a call to save() - identifier - This Property identifies the object in the API - volatile - Re-query for this Property if the local value is older than the - volatile refresh timeout - relationship - The API Object this Property represents - derived_class - The sub-collection type this Property represents - is_datetime - True if this Property should be parsed as a datetime.datetime - id_relationship - This Property should create a relationship with this key as the ID - (This should be used on fields ending with '_id' only) - slug_relationship - This property is a slug related for a given type. - nullable - This property can be explicitly null on PUT requests. - unordered - The order of this property is not significant. - NOTE: This field is currently only for annotations purposes - and does not influence any update or decoding/encoding logic. - json_object - The JSONObject class this property should be decoded into. + :param mutable: This Property should be sent in a call to save() + :type mutable: bool + :param identifier: This Property identifies the object in the API + :type identifier: bool + :param volatile: Re-query for this Property if the local value is older than the + volatile refresh timeout + :type volatile: bool + :param relationship: The API Object this Property represents + :type relationship: type or None + :param derived_class: The sub-collection type this Property represents + :type derived_class: type or None + :param is_datetime: True if this Property should be parsed as a datetime.datetime + :type is_datetime: bool + :param id_relationship: This Property should create a relationship with this key as the ID + (This should be used on fields ending with '_id' only) + :type id_relationship: type or None + :param slug_relationship: This property is a slug related for a given type + :type slug_relationship: type or None + :param nullable: This property can be explicitly null on PUT requests + :type nullable: bool + :param unordered: The order of this property is not significant. + NOTE: This field is currently only for annotations purposes + and does not influence any update or decoding/encoding logic. + :type unordered: bool + :param json_object: The JSONObject class this property should be decoded into + :type json_object: type or None + :param alias_of: The original API attribute name when the property key is aliased. + This is useful when the API attribute name is a Python reserved word, + allowing you to use a different key while preserving the original name. + :type alias_of: str or None """ self.mutable = mutable self.identifier = identifier @@ -68,6 +86,7 @@ def __init__( self.nullable = nullable self.unordered = unordered self.json_class = json_object + self.alias_of = alias_of class MappedObject: @@ -252,6 +271,21 @@ def __setattr__(self, name, value): self._set(name, value) + @cached_property + def properties_with_alias(self) -> dict[str, tuple[str, Property]]: + """ + Gets a dictionary of aliased properties for this object. + + :returns: A dict mapping original API attribute names to their alias names and + corresponding Property instances. + :rtype: dict[str, tuple[str, Property]] + """ + return { + prop.alias_of: (alias, prop) + for alias, prop in type(self).properties.items() + if prop.alias_of + } + def save(self, force=True) -> bool: """ Send this object's mutable values to the server in a PUT request. @@ -345,7 +379,8 @@ def _serialize(self, is_put: bool = False): ): value = None - result[k] = value + api_key = k if not v.alias_of else v.alias_of + result[api_key] = value # Resolve the underlying IDs of results for k, v in result.items(): @@ -373,55 +408,56 @@ def _populate(self, json): self._set("_raw_json", json) self._set("_updated", False) - for key in json: - if key in ( - k - for k in type(self).properties.keys() - if not type(self).properties[k].identifier + for api_key in json: + if api_key in chain( + ( + k + for k, v in type(self).properties.items() + if (not v.identifier) and (not v.alias_of) + ), # Exclude identifiers and aliased properties to avoid conflicts with API attributes + self.properties_with_alias.keys(), ): - if ( - type(self).properties[key].relationship - and not json[key] is None - ): - if isinstance(json[key], list): + prop = type(self).properties.get(api_key) + prop_key = api_key + + if prop is None: + prop_key, prop = self.properties_with_alias[api_key] + + if prop.relationship and not json[api_key] is None: + if isinstance(json[api_key], list): objs = [] - for d in json[key]: + for d in json[api_key]: if not "id" in d: continue - new_class = type(self).properties[key].relationship + new_class = prop.relationship obj = new_class.make_instance( d["id"], getattr(self, "_client") ) if obj: obj._populate(d) objs.append(obj) - self._set(key, objs) + self._set(prop_key, objs) else: - if isinstance(json[key], dict): - related_id = json[key]["id"] + if isinstance(json[api_key], dict): + related_id = json[api_key]["id"] else: - related_id = json[key] - new_class = type(self).properties[key].relationship + related_id = json[api_key] + new_class = prop.relationship obj = new_class.make_instance( related_id, getattr(self, "_client") ) - if obj and isinstance(json[key], dict): - obj._populate(json[key]) - self._set(key, obj) - elif ( - type(self).properties[key].slug_relationship - and not json[key] is None - ): + if obj and isinstance(json[api_key], dict): + obj._populate(json[api_key]) + self._set(prop_key, obj) + elif prop.slug_relationship and not json[api_key] is None: # create an object of the expected type with the given slug self._set( - key, - type(self) - .properties[key] - .slug_relationship(self._client, json[key]), + prop_key, + prop.slug_relationship(self._client, json[api_key]), ) - elif type(self).properties[key].json_class: - json_class = type(self).properties[key].json_class - json_value = json[key] + elif prop.json_class: + json_class = prop.json_class + json_value = json[api_key] # build JSON object if isinstance(json_value, list): @@ -430,25 +466,29 @@ def _populate(self, json): else: value = json_class.from_json(json_value) - self._set(key, value) - elif type(json[key]) is dict: - self._set(key, MappedObject(**json[key])) - elif type(json[key]) is list: + self._set(prop_key, value) + elif type(json[api_key]) is dict: + self._set(prop_key, MappedObject(**json[api_key])) + elif type(json[api_key]) is list: # we're going to use MappedObject's behavior with lists to # expand these, then grab the resulting value to set - mapping = MappedObject(_list=json[key]) - self._set(key, mapping._list) # pylint: disable=no-member - elif type(self).properties[key].is_datetime: + mapping = MappedObject(_list=json[api_key]) + self._set( + prop_key, mapping._list + ) # pylint: disable=no-member + elif prop.is_datetime: try: - t = time.strptime(json[key], DATE_FORMAT) - self._set(key, datetime.fromtimestamp(time.mktime(t))) + t = time.strptime(json[api_key], DATE_FORMAT) + self._set( + prop_key, datetime.fromtimestamp(time.mktime(t)) + ) except: # if this came back, there's probably an issue with the # python library; a field was marked as a datetime but # wasn't in the expected format. - self._set(key, json[key]) + self._set(prop_key, json[api_key]) else: - self._set(key, json[key]) + self._set(prop_key, json[api_key]) self._set("_populated", True) self._set("_last_updated", datetime.now()) diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index c957aa584..45d5c5102 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -19,4 +19,5 @@ class BetaProgram(Base): "ended": Property(is_datetime=True), "greenlight_only": Property(), "more_info": Property(), + "beta_class": Property(alias_of="class"), } diff --git a/test/unit/objects/property_alias_test.py b/test/unit/objects/property_alias_test.py new file mode 100644 index 000000000..09efa0e7e --- /dev/null +++ b/test/unit/objects/property_alias_test.py @@ -0,0 +1,191 @@ +""" +Tests for Property alias_of functionality +""" + +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, Property + + +class PropertyAliasTest(ClientBaseCase): + """Test cases for Property alias_of parameter""" + + def test_alias_populate_from_json(self): + """Test that aliased properties are populated correctly from JSON""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be set using the Python-friendly name + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + + def test_alias_serialize(self): + """Test that aliased properties serialize back to original API names""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + obj._set("service_class", "premium") + obj._set("label", "test-label") + obj._set("_populated", True) + + result = obj._serialize() + + # The serialized output should use the original API attribute name + self.assertIn("class", result) + self.assertEqual(result["class"], "premium") + self.assertEqual(result["label"], "test-label") + # Should not contain the aliased name + self.assertNotIn("service_class", result) + + def test_properties_with_alias(self): + """Test that properties_with_alias returns correct mapping""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(alias_of="type"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + + alias_map = obj.properties_with_alias + + # Should contain mappings for aliased properties + self.assertIn("class", alias_map) + self.assertIn("type", alias_map) + + # Should map to tuples of (alias_name, Property) + alias_name, prop = alias_map["class"] + self.assertEqual(alias_name, "service_class") + self.assertEqual(prop.alias_of, "class") + + alias_name, prop = alias_map["type"] + self.assertEqual(alias_name, "beta_type") + self.assertEqual(prop.alias_of, "type") + + # Non-aliased properties should not be in the map + self.assertNotIn("label", alias_map) + self.assertNotIn("id", alias_map) + + def test_alias_no_conflict_with_regular_properties(self): + """Test that aliased properties don't conflict with regular properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + "status": Property(), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + "status": "active", + } + + obj = TestModel(self.client, 123, json_data) + + # All properties should be set correctly + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + self.assertEqual(obj.status, "active") + + def test_multiple_aliases(self): + """Test handling multiple aliased properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(mutable=True, alias_of="type"), + "import_data": Property(mutable=True, alias_of="import"), + } + + json_data = { + "id": 123, + "class": "premium", + "type": "beta", + "import": "data", + } + + obj = TestModel(self.client, 123, json_data) + + # All aliased properties should be populated + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.beta_type, "beta") + self.assertEqual(obj.import_data, "data") + + # Serialization should use original names + obj._set("_populated", True) + result = obj._serialize() + + self.assertEqual(result["class"], "premium") + self.assertEqual(result["type"], "beta") + self.assertEqual(result["import"], "data") + + def test_alias_with_none_value(self): + """Test that aliased properties handle None values correctly""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + } + + json_data = { + "id": 123, + "class": None, + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be None + self.assertIsNone(obj.service_class) + + def test_alias_cached_property(self): + """Test that properties_with_alias is cached""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(alias_of="class"), + } + + obj = TestModel(self.client, 123) + + # Access the cached property twice + result1 = obj.properties_with_alias + result2 = obj.properties_with_alias + + # Should return the same object (cached) + self.assertIs(result1, result2)