Skip to content

Commit b2eff93

Browse files
Implement JSONObject put_class ClassVar (#534)
1 parent 0ecf79a commit b2eff93

File tree

5 files changed

+99
-22
lines changed

5 files changed

+99
-22
lines changed

linode_api4/objects/account.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,7 @@ def entity(self):
601601
)
602602
return self.cls(self._client, self.id)
603603

604-
def _serialize(self):
604+
def _serialize(self, *args, **kwargs):
605605
"""
606606
Returns this grant in as JSON the api will accept. This is only relevant
607607
in the context of UserGrants.save
@@ -668,7 +668,7 @@ def _grants_dict(self):
668668

669669
return grants
670670

671-
def _serialize(self):
671+
def _serialize(self, *args, **kwargs):
672672
"""
673673
Returns the user grants in as JSON the api will accept.
674674
This is only relevant in the context of UserGrants.save

linode_api4/objects/base.py

+16-10
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]:
114114

115115
@property
116116
def dict(self):
117+
return self._serialize()
118+
119+
def _serialize(self, is_put: bool = False) -> Dict[str, Any]:
117120
result = vars(self).copy()
118121
cls = type(self)
119122

@@ -123,7 +126,7 @@ def dict(self):
123126
elif isinstance(v, list):
124127
result[k] = [
125128
(
126-
item.dict
129+
item._serialize(is_put=is_put)
127130
if isinstance(item, (cls, JSONObject))
128131
else (
129132
self._flatten_base_subclass(item)
@@ -136,7 +139,7 @@ def dict(self):
136139
elif isinstance(v, Base):
137140
result[k] = self._flatten_base_subclass(v)
138141
elif isinstance(v, JSONObject):
139-
result[k] = v.dict
142+
result[k] = v._serialize(is_put=is_put)
140143

141144
return result
142145

@@ -278,9 +281,9 @@ def save(self, force=True) -> bool:
278281
data[key] = None
279282

280283
# Ensure we serialize any values that may not be already serialized
281-
data = _flatten_request_body_recursive(data)
284+
data = _flatten_request_body_recursive(data, is_put=True)
282285
else:
283-
data = self._serialize()
286+
data = self._serialize(is_put=True)
284287

285288
resp = self._client.put(type(self).api_endpoint, model=self, data=data)
286289

@@ -316,7 +319,7 @@ def invalidate(self):
316319

317320
self._set("_populated", False)
318321

319-
def _serialize(self):
322+
def _serialize(self, is_put: bool = False):
320323
"""
321324
A helper method to build a dict of all mutable Properties of
322325
this object
@@ -345,7 +348,7 @@ def _serialize(self):
345348

346349
# Resolve the underlying IDs of results
347350
for k, v in result.items():
348-
result[k] = _flatten_request_body_recursive(v)
351+
result[k] = _flatten_request_body_recursive(v, is_put=is_put)
349352

350353
return result
351354

@@ -503,7 +506,7 @@ def make_instance(cls, id, client, parent_id=None, json=None):
503506
return Base.make(id, client, cls, parent_id=parent_id, json=json)
504507

505508

506-
def _flatten_request_body_recursive(data: Any) -> Any:
509+
def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any:
507510
"""
508511
This is a helper recursively flatten the given data for use in an API request body.
509512
@@ -515,15 +518,18 @@ def _flatten_request_body_recursive(data: Any) -> Any:
515518
"""
516519

517520
if isinstance(data, dict):
518-
return {k: _flatten_request_body_recursive(v) for k, v in data.items()}
521+
return {
522+
k: _flatten_request_body_recursive(v, is_put=is_put)
523+
for k, v in data.items()
524+
}
519525

520526
if isinstance(data, list):
521-
return [_flatten_request_body_recursive(v) for v in data]
527+
return [_flatten_request_body_recursive(v, is_put=is_put) for v in data]
522528

523529
if isinstance(data, Base):
524530
return data.id
525531

526532
if isinstance(data, MappedObject) or issubclass(type(data), JSONObject):
527-
return data.dict
533+
return data._serialize(is_put=is_put)
528534

529535
return data

linode_api4/objects/linode.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ class ConfigInterface(JSONObject):
400400
def __repr__(self):
401401
return f"Interface: {self.purpose}"
402402

403-
def _serialize(self):
403+
def _serialize(self, *args, **kwargs):
404404
purpose_formats = {
405405
"public": {"purpose": "public", "primary": self.primary},
406406
"vlan": {
@@ -510,16 +510,16 @@ def _populate(self, json):
510510

511511
self._set("devices", MappedObject(**devices))
512512

513-
def _serialize(self):
513+
def _serialize(self, is_put: bool = False):
514514
"""
515515
Overrides _serialize to transform interfaces into json
516516
"""
517-
partial = DerivedBase._serialize(self)
517+
partial = DerivedBase._serialize(self, is_put=is_put)
518518
interfaces = []
519519

520520
for c in self.interfaces:
521521
if isinstance(c, ConfigInterface):
522-
interfaces.append(c._serialize())
522+
interfaces.append(c._serialize(is_put=is_put))
523523
else:
524524
interfaces.append(c)
525525

@@ -1927,8 +1927,8 @@ def _populate(self, json):
19271927
ndist = [Image(self._client, d) for d in self.images]
19281928
self._set("images", ndist)
19291929

1930-
def _serialize(self):
1931-
dct = Base._serialize(self)
1930+
def _serialize(self, is_put: bool = False):
1931+
dct = Base._serialize(self, is_put=is_put)
19321932
dct["images"] = [d.id for d in self.images]
19331933
return dct
19341934

linode_api4/objects/serializable.py

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import inspect
2-
from dataclasses import dataclass
2+
from dataclasses import dataclass, fields
33
from enum import Enum
44
from types import SimpleNamespace
55
from typing import (
@@ -9,6 +9,7 @@
99
List,
1010
Optional,
1111
Set,
12+
Type,
1213
Union,
1314
get_args,
1415
get_origin,
@@ -71,6 +72,13 @@ class JSONObject(metaclass=JSONFilterableMetaclass):
7172
are None.
7273
"""
7374

75+
put_class: ClassVar[Optional[Type["JSONObject"]]] = None
76+
"""
77+
An alternative JSONObject class to use as the schema for PUT requests.
78+
This prevents read-only fields from being included in PUT request bodies,
79+
which in theory will result in validation errors from the API.
80+
"""
81+
7482
def __init__(self):
7583
raise NotImplementedError(
7684
"JSONObject is not intended to be constructed directly"
@@ -154,19 +162,25 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]:
154162

155163
return obj
156164

157-
def _serialize(self) -> Dict[str, Any]:
165+
def _serialize(self, is_put: bool = False) -> Dict[str, Any]:
158166
"""
159167
Serializes this object into a JSON dict.
160168
"""
161169
cls = type(self)
170+
171+
if is_put and cls.put_class is not None:
172+
cls = cls.put_class
173+
174+
cls_field_keys = {field.name for field in fields(cls)}
175+
162176
type_hints = get_type_hints(cls)
163177

164178
def attempt_serialize(value: Any) -> Any:
165179
"""
166180
Attempts to serialize the given value, else returns the value unchanged.
167181
"""
168182
if issubclass(type(value), JSONObject):
169-
return value._serialize()
183+
return value._serialize(is_put=is_put)
170184

171185
return value
172186

@@ -175,6 +189,10 @@ def should_include(key: str, value: Any) -> bool:
175189
Returns whether the given key/value pair should be included in the resulting dict.
176190
"""
177191

192+
# During PUT operations, keys not present in the put_class should be excluded
193+
if key not in cls_field_keys:
194+
return False
195+
178196
if cls.include_none_values or key in cls.always_include:
179197
return True
180198

test/unit/objects/serializable_test.py

+54-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from test.unit.base import ClientBaseCase
33
from typing import Optional
44

5-
from linode_api4 import JSONObject
5+
from linode_api4 import Base, JSONObject, Property
66

77

88
class JSONObjectTest(ClientBaseCase):
@@ -47,3 +47,56 @@ class Foo(JSONObject):
4747
assert foo["foo"] == "test"
4848
assert foo["bar"] == "test2"
4949
assert foo["baz"] == "test3"
50+
51+
def test_serialize_put_class(self):
52+
"""
53+
Ensures that the JSONObject put_class ClassVar functions as expected.
54+
"""
55+
56+
@dataclass
57+
class SubStructOptions(JSONObject):
58+
test1: Optional[str] = None
59+
60+
@dataclass
61+
class SubStruct(JSONObject):
62+
put_class = SubStructOptions
63+
64+
test1: str = ""
65+
test2: int = 0
66+
67+
class Model(Base):
68+
api_endpoint = "/foo/bar"
69+
70+
properties = {
71+
"id": Property(identifier=True),
72+
"substruct": Property(mutable=True, json_object=SubStruct),
73+
}
74+
75+
mock_response = {
76+
"id": 123,
77+
"substruct": {
78+
"test1": "abc",
79+
"test2": 321,
80+
},
81+
}
82+
83+
with self.mock_get(mock_response) as mock:
84+
obj = self.client.load(Model, 123)
85+
86+
assert mock.called
87+
88+
assert obj.id == 123
89+
assert obj.substruct.test1 == "abc"
90+
assert obj.substruct.test2 == 321
91+
92+
obj.substruct.test1 = "cba"
93+
94+
with self.mock_put(mock_response) as mock:
95+
obj.save()
96+
97+
assert mock.called
98+
assert mock.call_data == {
99+
"substruct": {
100+
"test1": "cba",
101+
}
102+
}

0 commit comments

Comments
 (0)