From 0a76104fee88fc7bfba85f4fdf9b06e760631ffe Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 23 Apr 2025 16:27:59 -0400 Subject: [PATCH] Implement JSONObject put_class ClassVar --- linode_api4/objects/account.py | 4 +- linode_api4/objects/base.py | 26 +++++++----- linode_api4/objects/linode.py | 12 +++--- linode_api4/objects/serializable.py | 24 +++++++++-- test/unit/objects/serializable_test.py | 55 +++++++++++++++++++++++++- 5 files changed, 99 insertions(+), 22 deletions(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 375e5fc03..c7318d871 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -601,7 +601,7 @@ def entity(self): ) return self.cls(self._client, self.id) - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns this grant in as JSON the api will accept. This is only relevant in the context of UserGrants.save @@ -668,7 +668,7 @@ def _grants_dict(self): return grants - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns the user grants in as JSON the api will accept. This is only relevant in the context of UserGrants.save diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 6c9b1bece..c9a622edc 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -114,6 +114,9 @@ def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]: @property def dict(self): + return self._serialize() + + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: result = vars(self).copy() cls = type(self) @@ -123,7 +126,7 @@ def dict(self): elif isinstance(v, list): result[k] = [ ( - item.dict + item._serialize(is_put=is_put) if isinstance(item, (cls, JSONObject)) else ( self._flatten_base_subclass(item) @@ -136,7 +139,7 @@ def dict(self): elif isinstance(v, Base): result[k] = self._flatten_base_subclass(v) elif isinstance(v, JSONObject): - result[k] = v.dict + result[k] = v._serialize(is_put=is_put) return result @@ -278,9 +281,9 @@ def save(self, force=True) -> bool: data[key] = None # Ensure we serialize any values that may not be already serialized - data = _flatten_request_body_recursive(data) + data = _flatten_request_body_recursive(data, is_put=True) else: - data = self._serialize() + data = self._serialize(is_put=True) resp = self._client.put(type(self).api_endpoint, model=self, data=data) @@ -316,7 +319,7 @@ def invalidate(self): self._set("_populated", False) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ A helper method to build a dict of all mutable Properties of this object @@ -345,7 +348,7 @@ def _serialize(self): # Resolve the underlying IDs of results for k, v in result.items(): - result[k] = _flatten_request_body_recursive(v) + result[k] = _flatten_request_body_recursive(v, is_put=is_put) return result @@ -503,7 +506,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): return Base.make(id, client, cls, parent_id=parent_id, json=json) -def _flatten_request_body_recursive(data: Any) -> Any: +def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any: """ This is a helper recursively flatten the given data for use in an API request body. @@ -515,15 +518,18 @@ def _flatten_request_body_recursive(data: Any) -> Any: """ if isinstance(data, dict): - return {k: _flatten_request_body_recursive(v) for k, v in data.items()} + return { + k: _flatten_request_body_recursive(v, is_put=is_put) + for k, v in data.items() + } if isinstance(data, list): - return [_flatten_request_body_recursive(v) for v in data] + return [_flatten_request_body_recursive(v, is_put=is_put) for v in data] if isinstance(data, Base): return data.id if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): - return data.dict + return data._serialize(is_put=is_put) return data diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 46af5d970..c70dd7965 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -400,7 +400,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self): + def _serialize(self, *args, **kwargs): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -510,16 +510,16 @@ def _populate(self, json): self._set("devices", MappedObject(**devices)) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ Overrides _serialize to transform interfaces into json """ - partial = DerivedBase._serialize(self) + partial = DerivedBase._serialize(self, is_put=is_put) interfaces = [] for c in self.interfaces: if isinstance(c, ConfigInterface): - interfaces.append(c._serialize()) + interfaces.append(c._serialize(is_put=is_put)) else: interfaces.append(c) @@ -1927,8 +1927,8 @@ def _populate(self, json): ndist = [Image(self._client, d) for d in self.images] self._set("images", ndist) - def _serialize(self): - dct = Base._serialize(self) + def _serialize(self, is_put: bool = False): + dct = Base._serialize(self, is_put=is_put) dct["images"] = [d.id for d in self.images] return dct diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index fea682f43..e33179a60 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,5 @@ import inspect -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum from types import SimpleNamespace from typing import ( @@ -9,6 +9,7 @@ List, Optional, Set, + Type, Union, get_args, get_origin, @@ -71,6 +72,13 @@ class JSONObject(metaclass=JSONFilterableMetaclass): are None. """ + put_class: ClassVar[Optional[Type["JSONObject"]]] = None + """ + An alternative JSONObject class to use as the schema for PUT requests. + This prevents read-only fields from being included in PUT request bodies, + which in theory will result in validation errors from the API. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" @@ -154,11 +162,17 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: return obj - def _serialize(self) -> Dict[str, Any]: + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ cls = type(self) + + if is_put and cls.put_class is not None: + cls = cls.put_class + + cls_field_keys = {field.name for field in fields(cls)} + type_hints = get_type_hints(cls) def attempt_serialize(value: Any) -> Any: @@ -166,7 +180,7 @@ def attempt_serialize(value: Any) -> Any: Attempts to serialize the given value, else returns the value unchanged. """ if issubclass(type(value), JSONObject): - return value._serialize() + return value._serialize(is_put=is_put) return value @@ -175,6 +189,10 @@ def should_include(key: str, value: Any) -> bool: Returns whether the given key/value pair should be included in the resulting dict. """ + # During PUT operations, keys not present in the put_class should be excluded + if key not in cls_field_keys: + return False + if cls.include_none_values or key in cls.always_include: return True diff --git a/test/unit/objects/serializable_test.py b/test/unit/objects/serializable_test.py index a15f108b4..9a775ccf1 100644 --- a/test/unit/objects/serializable_test.py +++ b/test/unit/objects/serializable_test.py @@ -2,7 +2,7 @@ from test.unit.base import ClientBaseCase from typing import Optional -from linode_api4 import JSONObject +from linode_api4 import Base, JSONObject, Property class JSONObjectTest(ClientBaseCase): @@ -47,3 +47,56 @@ class Foo(JSONObject): assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + + def test_serialize_put_class(self): + """ + Ensures that the JSONObject put_class ClassVar functions as expected. + """ + + @dataclass + class SubStructOptions(JSONObject): + test1: Optional[str] = None + + @dataclass + class SubStruct(JSONObject): + put_class = SubStructOptions + + test1: str = "" + test2: int = 0 + + class Model(Base): + api_endpoint = "/foo/bar" + + properties = { + "id": Property(identifier=True), + "substruct": Property(mutable=True, json_object=SubStruct), + } + + mock_response = { + "id": 123, + "substruct": { + "test1": "abc", + "test2": 321, + }, + } + + with self.mock_get(mock_response) as mock: + obj = self.client.load(Model, 123) + + assert mock.called + + assert obj.id == 123 + assert obj.substruct.test1 == "abc" + assert obj.substruct.test2 == 321 + + obj.substruct.test1 = "cba" + + with self.mock_put(mock_response) as mock: + obj.save() + + assert mock.called + assert mock.call_data == { + "substruct": { + "test1": "cba", + } + }