Skip to content

Commit b406990

Browse files
Allow dict passthrough for config_create 'devices' field
1 parent c9e18a5 commit b406990

7 files changed

Lines changed: 160 additions & 58 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
runs-on: ubuntu-latest
3232
strategy:
3333
matrix:
34-
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
34+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
3535
steps:
3636
- uses: actions/checkout@v6
3737
- uses: actions/setup-python@v6

.github/workflows/e2e-test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ on:
5555
- dev
5656

5757
env:
58-
DEFAULT_PYTHON_VERSION: "3.10"
59-
EOL_PYTHON_VERSION: "3.9"
58+
DEFAULT_PYTHON_VERSION: "3.13"
59+
EOL_PYTHON_VERSION: "3.10"
6060
EXIT_STATUS: 0
6161

6262
jobs:

linode_api4/objects/linode.py

Lines changed: 91 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import copy
24
import string
35
import sys
@@ -40,7 +42,11 @@
4042
from linode_api4.objects.serializable import JSONObject, StrEnum
4143
from linode_api4.objects.vpc import VPC, VPCSubnet
4244
from linode_api4.paginated_list import PaginatedList
43-
from linode_api4.util import drop_null_keys, generate_device_suffixes
45+
from linode_api4.util import (
46+
drop_null_keys,
47+
generate_device_suffixes,
48+
normalize_as_list,
49+
)
4450

4551
PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation
4652
MIN_DEVICE_LIMIT = 8
@@ -1246,14 +1252,14 @@ def _func(value):
12461252
# create derived objects
12471253
def config_create(
12481254
self,
1249-
kernel=None,
1250-
label=None,
1251-
devices=[],
1252-
disks=[],
1253-
volumes=[],
1254-
interfaces=[],
1255+
kernel: Kernel | str | None = None,
1256+
label: str | None = None,
1257+
devices: "Disk | Volume | dict[str, Any] | list[Disk | Volume | dict[str, Any]] | None" = None,
1258+
disks: Disk | int | list[Disk | int] | None = None,
1259+
volumes: "Volume | int | list[Volume | int] | None" = None,
1260+
interfaces: list[ConfigInterface | dict[str, Any]] | None = None,
12551261
**kwargs,
1256-
):
1262+
) -> Config:
12571263
"""
12581264
Creates a Linode Config with the given attributes.
12591265
@@ -1263,17 +1269,22 @@ def config_create(
12631269
:param label: The config label
12641270
:param disks: The list of disks, starting at sda, to map to this config.
12651271
:param volumes: The volumes, starting after the last disk, to map to this
1266-
config
1272+
config.
12671273
:param devices: A list of devices to assign to this config, in device
1268-
index order. Values must be of type Disk or Volume. If this is
1269-
given, you may not include disks or volumes.
1274+
index order, a raw device mapping dict to pass directly to the API
1275+
(e.g. ``{"sda": {"disk_id": 123}, "sdb": Volume(...)}``), or
1276+
a single Disk or Volume.
1277+
If this is given, you may not include disks or volumes.
1278+
:param interfaces: A list of ConfigInterface objects or dicts to assign to this config.
12701279
:param **kwargs: Any other arguments accepted by the api.
12711280
12721281
:returns: A new Linode Config
12731282
"""
12741283
# needed here to avoid circular imports
12751284
from .volume import Volume # pylint: disable=import-outside-toplevel
12761285

1286+
interfaces = [] if interfaces is None else interfaces
1287+
12771288
hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd"
12781289

12791290
device_limit = int(
@@ -1288,52 +1299,77 @@ def config_create(
12881299
for suffix in generate_device_suffixes(device_limit)
12891300
]
12901301

1291-
device_map = {
1292-
device_names[i]: None for i in range(0, len(device_names))
1293-
}
1302+
def _flatten_device(device: Disk | Volume | None):
1303+
if device is None:
1304+
return None
1305+
elif isinstance(device, Disk):
1306+
return {"disk_id": device.id}
1307+
elif isinstance(device, Volume):
1308+
return {"volume_id": device.id}
1309+
1310+
raise TypeError("Disk or Volume expected!")
1311+
1312+
def _device_entry(device: Disk | Volume | int, key: str):
1313+
if isinstance(device, (Disk, Volume)):
1314+
return _flatten_device(device)
1315+
1316+
try:
1317+
device_id = int(device)
1318+
except (TypeError, ValueError):
1319+
raise TypeError(
1320+
"Disk, Volume, or integer ID expected!"
1321+
) from None
1322+
1323+
return {key: device_id}
1324+
1325+
def _build_devices():
1326+
# Devices is a dict, flatten and pass through
1327+
if isinstance(devices, dict):
1328+
return {
1329+
k: (
1330+
_flatten_device(v)
1331+
if isinstance(v, (Disk, Volume))
1332+
else v
1333+
)
1334+
for k, v in devices.items()
1335+
}
1336+
1337+
device_list = []
12941338

1339+
if devices:
1340+
device_list += [
1341+
_flatten_device(device)
1342+
for device in normalize_as_list(devices)
1343+
]
1344+
1345+
if disks:
1346+
device_list += [
1347+
_device_entry(disk, "disk_id")
1348+
for disk in normalize_as_list(disks)
1349+
]
1350+
1351+
if volumes:
1352+
device_list += [
1353+
_device_entry(volume, "volume_id")
1354+
for volume in normalize_as_list(volumes)
1355+
]
1356+
1357+
return {
1358+
device_names[i]: device for i, device in enumerate(device_list)
1359+
}
1360+
1361+
# This validation is enforced for backwards compatibility but isn't
1362+
# technically needed anymore
12951363
if devices and (disks or volumes):
12961364
raise ValueError(
12971365
'You may not call config_create with "devices" and '
12981366
'either of "disks" or "volumes" specified!'
12991367
)
13001368

1301-
if not devices:
1302-
if not isinstance(disks, list):
1303-
disks = [disks]
1304-
if not isinstance(volumes, list):
1305-
volumes = [volumes]
1306-
1307-
devices = []
1308-
1309-
for d in disks:
1310-
if d is None:
1311-
devices.append(None)
1312-
elif isinstance(d, Disk):
1313-
devices.append(d)
1314-
else:
1315-
devices.append(Disk(self._client, int(d), self.id))
1316-
1317-
for v in volumes:
1318-
if v is None:
1319-
devices.append(None)
1320-
elif isinstance(v, Volume):
1321-
devices.append(v)
1322-
else:
1323-
devices.append(Volume(self._client, int(v)))
1324-
1325-
if not devices:
1326-
raise ValueError("Must include at least one disk or volume!")
1369+
device_map = _build_devices()
13271370

1328-
for i, d in enumerate(devices):
1329-
if d is None:
1330-
pass
1331-
elif isinstance(d, Disk):
1332-
device_map[device_names[i]] = {"disk_id": d.id}
1333-
elif isinstance(d, Volume):
1334-
device_map[device_names[i]] = {"volume_id": d.id}
1335-
else:
1336-
raise TypeError("Disk or Volume expected!")
1371+
if len(device_map) < 1:
1372+
raise ValueError("Must include at least one disk or volume!")
13371373

13381374
param_interfaces = []
13391375
for interface in interfaces:
@@ -1845,8 +1881,8 @@ def clone(
18451881
to_linode=None,
18461882
region=None,
18471883
instance_type=None,
1848-
configs=[],
1849-
disks=[],
1884+
configs=None,
1885+
disks=None,
18501886
label=None,
18511887
group=None,
18521888
with_backups=None,
@@ -1902,7 +1938,10 @@ def clone(
19021938
'You may only specify one of "to_linode" and "region"'
19031939
)
19041940

1905-
if region and not type:
1941+
configs = [] if configs is None else configs
1942+
disks = [] if disks is None else disks
1943+
1944+
if region and not instance_type:
19061945
raise ValueError('Specifying a region requires a "service" as well')
19071946

19081947
if not isinstance(configs, list) and not isinstance(

linode_api4/util.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
import string
6-
from typing import Any, Dict
6+
from typing import Any, Dict, List, Tuple, Union
77

88

99
def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]:
@@ -30,6 +30,13 @@ def recursive_helper(value: Any) -> Any:
3030
return recursive_helper(data)
3131

3232

33+
def normalize_as_list(value: Any) -> Union[List, Tuple]:
34+
"""
35+
Returns the value wrapped in a list if it isn't already a list or tuple.
36+
"""
37+
return value if isinstance(value, (list, tuple)) else [value]
38+
39+
3340
def generate_device_suffixes(n: int) -> list[str]:
3441
"""
3542
Generate n alphabetical suffixes starting with a, b, c, etc.

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ name = "linode_api4"
88
authors = [{ name = "Linode", email = "devs@linode.com" }]
99
description = "The official Python SDK for Linode API v4"
1010
readme = "README.rst"
11-
requires-python = ">=3.9"
11+
requires-python = ">=3.10"
1212
keywords = [
1313
"akamai",
1414
"Akamai Connected Cloud",
@@ -25,10 +25,11 @@ classifiers = [
2525
"License :: OSI Approved :: BSD License",
2626
"Programming Language :: Python",
2727
"Programming Language :: Python :: 3",
28-
"Programming Language :: Python :: 3.9",
2928
"Programming Language :: Python :: 3.10",
3029
"Programming Language :: Python :: 3.11",
3130
"Programming Language :: Python :: 3.12",
31+
"Programming Language :: Python :: 3.13",
32+
"Programming Language :: Python :: 3.14",
3233
]
3334
dependencies = ["requests", "polling", "deprecated"]
3435
dynamic = ["version"]

test/integration/models/volume/test_blockstorage.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,30 @@ def test_config_create_with_extended_volume_limit(test_linode_client):
3838
linode.delete()
3939
for v in volumes:
4040
retry_sending_request(3, v.delete)
41+
42+
43+
def test_config_create_with_device_map(test_linode_client):
44+
client = test_linode_client
45+
46+
region = get_region(client, {"Linodes", "Block Storage"}, site_type="core")
47+
label = get_test_label()
48+
49+
linode, _ = client.linode.instance_create(
50+
"g6-standard-6",
51+
region,
52+
image="linode/debian12",
53+
label=label,
54+
)
55+
56+
disk_id = linode.disks[0].id
57+
devices = {
58+
"sdl": {"disk_id": disk_id},
59+
}
60+
61+
config = linode.config_create(label=f"{label}-config", devices=devices)
62+
63+
result_devices = config._raw_json["devices"]
64+
assert result_devices["sdl"] is not None
65+
assert result_devices["sdl"]["disk_id"] == disk_id
66+
67+
linode.delete()

test/unit/objects/linode_test.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,34 @@ def test_create_disk(self):
459459
assert disk.id == 12345
460460
assert disk.disk_encryption == InstanceDiskEncryptionType.disabled
461461

462+
def test_create_config_with_device_map(self):
463+
"""
464+
Tests that config_create passes through a raw device map unchanged.
465+
"""
466+
linode = Instance(self.client, 123)
467+
devices = {
468+
"sda": {"disk_id": 111},
469+
"sdb": {"volume_id": 222},
470+
"sdc": None,
471+
}
472+
473+
with self.mock_post(
474+
{"id": 456, "devices": devices, "interfaces": []}
475+
) as m:
476+
config = linode.config_create(label="test-config", devices=devices)
477+
478+
self.assertEqual(m.call_url, "/linode/instances/123/configs")
479+
self.assertEqual(
480+
m.call_data,
481+
{
482+
"label": "test-config",
483+
"devices": devices,
484+
"interfaces": [],
485+
},
486+
)
487+
488+
self.assertEqual(config.id, 456)
489+
462490
def test_get_placement_group(self):
463491
"""
464492
Tests that you can get the placement group for a Linode

0 commit comments

Comments
 (0)