Skip to content

Commit 79f73cf

Browse files
authored
Merge pull request #537 from linode/dev
Release 5.29.1
2 parents d432d5e + c449113 commit 79f73cf

File tree

11 files changed

+121
-39
lines changed

11 files changed

+121
-39
lines changed

.github/workflows/labeler.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
uses: actions/checkout@v4
2222
-
2323
name: Run Labeler
24-
uses: crazy-max/ghaction-github-labeler@31674a3852a9074f2086abcf1c53839d466a47e7
24+
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916
2525
with:
2626
github-token: ${{ secrets.GITHUB_TOKEN }}
2727
yaml-file: .github/labels.yml

linode_api4/objects/account.py

Lines changed: 2 additions & 2 deletions
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

Lines changed: 16 additions & 10 deletions
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

Lines changed: 6 additions & 6 deletions
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

Lines changed: 21 additions & 3 deletions
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/integration/login_client/test_login_client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def test_linode_login_client_generate_login_url_with_scope(linode_login_client):
9797
assert "scopes=linodes%3Aread_write" in url
9898

9999

100+
@pytest.mark.skip("Endpoint may be deprecated")
100101
def test_linode_login_client_expire_token(
101102
linode_login_client, test_oauth_client
102103
):

test/integration/models/domain/test_domain.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ def test_save_null_values_excluded(test_linode_client, test_domain):
2323
domain.master_ips = ["127.0.0.1"]
2424
res = domain.save()
2525

26-
assert res
27-
2826

2927
def test_zone_file_view(test_linode_client, test_domain):
3028
domain = test_linode_client.load(Domain, test_domain.id)

test/integration/models/linode/test_linode.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99

1010
import pytest
1111

12-
from linode_api4 import VPCIPAddress
1312
from linode_api4.errors import ApiError
1413
from linode_api4.objects import (
1514
Config,
@@ -181,7 +180,7 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall):
181180
def linode_with_disk_encryption(test_linode_client, request):
182181
client = test_linode_client
183182

184-
target_region = get_region(client, {"Disk Encryption"})
183+
target_region = get_region(client, {"LA Disk Encryption"})
185184
label = get_test_label(length=8)
186185

187186
disk_encryption = request.param
@@ -236,7 +235,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall):
236235
def test_linode_rebuild(test_linode_client):
237236
client = test_linode_client
238237

239-
region = get_region(client, {"Disk Encryption"})
238+
region = get_region(client, {"LA Disk Encryption"})
240239

241240
label = get_test_label() + "_rebuild"
242241

@@ -365,6 +364,7 @@ def test_linode_resize(create_linode_for_long_running_tests):
365364
assert linode.status == "running"
366365

367366

367+
@pytest.mark.flaky(reruns=3, reruns_delay=2)
368368
def test_linode_resize_with_class(
369369
test_linode_client, create_linode_for_long_running_tests
370370
):
@@ -535,6 +535,7 @@ def test_linode_create_disk(test_linode_client, linode_for_disk_tests):
535535
assert disk.linode_id == linode.id
536536

537537

538+
@pytest.mark.flaky(reruns=3, reruns_delay=2)
538539
def test_linode_instance_password(create_linode_for_pass_reset):
539540
linode = create_linode_for_pass_reset[0]
540541
password = create_linode_for_pass_reset[1]
@@ -775,10 +776,10 @@ def test_create_vpc(
775776
assert vpc_range_ip.address_range == "10.0.0.5/32"
776777
assert not vpc_range_ip.active
777778

779+
# TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back
780+
778781
# Attempt to resolve the IP from /vpcs/ips
779-
all_vpc_ips = test_linode_client.vpcs.ips(
780-
VPCIPAddress.filters.linode_id == linode.id
781-
)
782+
all_vpc_ips = test_linode_client.vpcs.ips()
782783
assert all_vpc_ips[0].dict == vpc_ip.dict
783784

784785
# Test getting the ips under this specific VPC

test/integration/models/lke/test_lke.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ def lke_cluster(test_linode_client):
3232
node_type = test_linode_client.linode.types()[1] # g6-standard-1
3333
version = test_linode_client.lke.versions()[0]
3434

35-
region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"})
35+
region = get_region(
36+
test_linode_client, {"Kubernetes", "LA Disk Encryption"}
37+
)
3638

3739
node_pools = test_linode_client.lke.node_pool(node_type, 3)
3840
label = get_test_label() + "_cluster"
@@ -115,7 +117,9 @@ def lke_cluster_with_labels_and_taints(test_linode_client):
115117
def lke_cluster_with_apl(test_linode_client):
116118
version = test_linode_client.lke.versions()[0]
117119

118-
region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"})
120+
region = get_region(
121+
test_linode_client, {"Kubernetes", "LA Disk Encryption"}
122+
)
119123

120124
# NOTE: g6-dedicated-4 is the minimum APL-compatible Linode type
121125
node_pools = test_linode_client.lke.node_pool("g6-dedicated-4", 3)
@@ -145,7 +149,7 @@ def lke_cluster_enterprise(test_linode_client):
145149
)[0]
146150

147151
region = get_region(
148-
test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"}
152+
test_linode_client, {"Kubernetes Enterprise", "LA Disk Encryption"}
149153
)
150154

151155
node_pools = test_linode_client.lke.node_pool(
@@ -204,7 +208,7 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]:
204208

205209
assert _to_comparable(cluster.pools[0]) == _to_comparable(pool)
206210

207-
assert pool.disk_encryption == InstanceDiskEncryptionType.enabled
211+
assert pool.disk_encryption == InstanceDiskEncryptionType.disabled
208212

209213

210214
def test_cluster_dashboard_url_view(lke_cluster):

test/integration/models/object_storage/test_obj.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import time
2-
from test.integration.conftest import get_region
2+
from test.integration.helpers import send_request_when_resource_available
33

44
import pytest
55

@@ -18,7 +18,7 @@
1818

1919
@pytest.fixture(scope="session")
2020
def region(test_linode_client: LinodeClient):
21-
return get_region(test_linode_client, {"Object Storage"}).id
21+
return "us-southeast" # uncomment get_region(test_linode_client, {"Object Storage"}).id
2222

2323

2424
@pytest.fixture(scope="session")
@@ -38,7 +38,7 @@ def bucket(
3838
)
3939

4040
yield bucket
41-
bucket.delete()
41+
send_request_when_resource_available(timeout=100, func=bucket.delete)
4242

4343

4444
@pytest.fixture(scope="session")
@@ -63,7 +63,8 @@ def bucket_with_endpoint(
6363
)
6464

6565
yield bucket
66-
bucket.delete()
66+
67+
send_request_when_resource_available(timeout=100, func=bucket.delete)
6768

6869

6970
@pytest.fixture(scope="session")

0 commit comments

Comments
 (0)