diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd8eeea17..a9b5e4336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index df1a41841..1298af319 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -55,8 +55,8 @@ on: - dev env: - DEFAULT_PYTHON_VERSION: "3.10" - EOL_PYTHON_VERSION: "3.9" + DEFAULT_PYTHON_VERSION: "3.13" + EOL_PYTHON_VERSION: "3.10" EXIT_STATUS: 0 jobs: diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3ffe4b232..9b76668e3 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import string import sys @@ -40,7 +42,11 @@ from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList -from linode_api4.util import drop_null_keys, generate_device_suffixes +from linode_api4.util import ( + drop_null_keys, + generate_device_suffixes, + normalize_as_list, +) PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation MIN_DEVICE_LIMIT = 8 @@ -1246,14 +1252,14 @@ def _func(value): # create derived objects def config_create( self, - kernel=None, - label=None, - devices=[], - disks=[], - volumes=[], - interfaces=[], + kernel: Kernel | str | None = None, + label: str | None = None, + devices: "Disk | Volume | dict[str, Any] | list[Disk | Volume | dict[str, Any]] | None" = None, + disks: Disk | int | list[Disk | int] | None = None, + volumes: "Volume | int | list[Volume | int] | None" = None, + interfaces: list[ConfigInterface | dict[str, Any]] | None = None, **kwargs, - ): + ) -> Config: """ Creates a Linode Config with the given attributes. @@ -1263,10 +1269,13 @@ def config_create( :param label: The config label :param disks: The list of disks, starting at sda, to map to this config. :param volumes: The volumes, starting after the last disk, to map to this - config + config. :param devices: A list of devices to assign to this config, in device - index order. Values must be of type Disk or Volume. If this is - given, you may not include disks or volumes. + index order, a raw device mapping dict to pass directly to the API + (e.g. ``{"sda": {"disk_id": 123}, "sdb": Volume(...)}``), or + a single Disk or Volume. + If this is given, you may not include disks or volumes. + :param interfaces: A list of ConfigInterface objects or dicts to assign to this config. :param **kwargs: Any other arguments accepted by the api. :returns: A new Linode Config @@ -1274,6 +1283,8 @@ def config_create( # needed here to avoid circular imports from .volume import Volume # pylint: disable=import-outside-toplevel + interfaces = [] if interfaces is None else interfaces + hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" device_limit = int( @@ -1288,52 +1299,83 @@ def config_create( for suffix in generate_device_suffixes(device_limit) ] - device_map = { - device_names[i]: None for i in range(0, len(device_names)) - } + def _flatten_device(device: Disk | Volume | dict | None): + if device is None: + return None + elif isinstance(device, Disk): + return {"disk_id": device.id} + elif isinstance(device, Volume): + return {"volume_id": device.id} + elif isinstance(device, dict): + return device + + raise TypeError("Disk, Volume, or dict expected!") + + def _device_entry(device: Disk | Volume | int, key: str): + if isinstance(device, (Disk, Volume)): + return _flatten_device(device) + + try: + device_id = int(device) + except (TypeError, ValueError): + raise TypeError( + "Disk, Volume, or integer ID expected!" + ) from None + + return {key: device_id} + + def _build_devices(): + # Devices is a dict, flatten and pass through + if isinstance(devices, dict): + return { + k: ( + _flatten_device(v) + if isinstance(v, (Disk, Volume)) + else v + ) + for k, v in devices.items() + } + device_list = [] + + if devices: + device_list += [ + _flatten_device(device) + for device in normalize_as_list(devices) + ] + + if disks: + device_list += [ + _device_entry(disk, "disk_id") if disk is not None else None + for disk in normalize_as_list(disks) + ] + + if volumes: + device_list += [ + ( + _device_entry(volume, "volume_id") + if volume is not None + else None + ) + for volume in normalize_as_list(volumes) + ] + + return { + device_names[i]: device for i, device in enumerate(device_list) + } + + # This validation is enforced for backwards compatibility but isn't + # technically needed anymore if devices and (disks or volumes): raise ValueError( 'You may not call config_create with "devices" and ' 'either of "disks" or "volumes" specified!' ) - if not devices: - if not isinstance(disks, list): - disks = [disks] - if not isinstance(volumes, list): - volumes = [volumes] - - devices = [] - - for d in disks: - if d is None: - devices.append(None) - elif isinstance(d, Disk): - devices.append(d) - else: - devices.append(Disk(self._client, int(d), self.id)) - - for v in volumes: - if v is None: - devices.append(None) - elif isinstance(v, Volume): - devices.append(v) - else: - devices.append(Volume(self._client, int(v))) - - if not devices: - raise ValueError("Must include at least one disk or volume!") + device_map = _build_devices() - for i, d in enumerate(devices): - if d is None: - pass - elif isinstance(d, Disk): - device_map[device_names[i]] = {"disk_id": d.id} - elif isinstance(d, Volume): - device_map[device_names[i]] = {"volume_id": d.id} - else: - raise TypeError("Disk or Volume expected!") + if len(device_map) < 1: + raise ValueError("Must include at least one disk or volume!") param_interfaces = [] for interface in interfaces: @@ -1845,8 +1887,8 @@ def clone( to_linode=None, region=None, instance_type=None, - configs=[], - disks=[], + configs=None, + disks=None, label=None, group=None, with_backups=None, @@ -1902,7 +1944,10 @@ def clone( 'You may only specify one of "to_linode" and "region"' ) - if region and not type: + configs = [] if configs is None else configs + disks = [] if disks is None else disks + + if region and not instance_type: raise ValueError('Specifying a region requires a "service" as well') if not isinstance(configs, list) and not isinstance( diff --git a/linode_api4/util.py b/linode_api4/util.py index f661367af..0ba6b8e09 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -3,7 +3,7 @@ """ import string -from typing import Any, Dict +from typing import Any, Dict, List, Tuple, Union def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: @@ -30,6 +30,13 @@ def recursive_helper(value: Any) -> Any: return recursive_helper(data) +def normalize_as_list(value: Any) -> Union[List, Tuple]: + """ + Returns the value wrapped in a list if it isn't already a list or tuple. + """ + return value if isinstance(value, (list, tuple)) else [value] + + def generate_device_suffixes(n: int) -> list[str]: """ Generate n alphabetical suffixes starting with a, b, c, etc. diff --git a/pyproject.toml b/pyproject.toml index 4d8542cfa..7f3129d39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "linode_api4" authors = [{ name = "Linode", email = "devs@linode.com" }] description = "The official Python SDK for Linode API v4" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = [ "akamai", "Akamai Connected Cloud", @@ -25,10 +25,11 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = ["requests", "polling", "deprecated"] dynamic = ["version"] @@ -78,7 +79,7 @@ line_length = 80 [tool.black] line-length = 80 -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313", "py314"] [tool.autoflake] expand-star-imports = true diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 9f6194fa9..512b6c513 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -790,6 +790,15 @@ def test_get_config(test_linode_client, create_linode): assert config.id == linode.configs[0].id +def test_config_create_without_devices_raises_error(create_linode): + linode = create_linode + + with pytest.raises(ValueError) as err: + linode.config_create(label="test-config-no-devices") + + assert "Must include at least one disk or volume!" in str(err.value) + + def test_get_linode_types(test_linode_client): types = test_linode_client.linode.types() diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py index 8dac88e18..e382f4a2a 100644 --- a/test/integration/models/volume/test_blockstorage.py +++ b/test/integration/models/volume/test_blockstorage.py @@ -38,3 +38,30 @@ def test_config_create_with_extended_volume_limit(test_linode_client): linode.delete() for v in volumes: retry_sending_request(3, v.delete) + + +def test_config_create_with_device_map(test_linode_client): + client = test_linode_client + + region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") + label = get_test_label() + + linode, _ = client.linode.instance_create( + "g6-standard-6", + region, + image="linode/debian12", + label=label, + ) + + disk_id = linode.disks[0].id + devices = { + "sdl": {"disk_id": disk_id}, + } + + config = linode.config_create(label=f"{label}-config", devices=devices) + + result_devices = config._raw_json["devices"] + assert result_devices["sdl"] is not None + assert result_devices["sdl"]["disk_id"] == disk_id + + linode.delete() diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 40bbb5069..1c31f8109 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -459,6 +459,47 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_create_config_with_device_map(self): + """ + Tests that config_create passes through a raw device map unchanged. + """ + linode = Instance(self.client, 123) + devices = { + "sda": {"disk_id": 111}, + "sdb": {"volume_id": 222}, + "sdc": None, + } + + with self.mock_post( + {"id": 456, "devices": devices, "interfaces": []} + ) as m: + config = linode.config_create(label="test-config", devices=devices) + + self.assertEqual(m.call_url, "/linode/instances/123/configs") + self.assertEqual( + m.call_data, + { + "label": "test-config", + "devices": devices, + "interfaces": [], + }, + ) + + self.assertEqual(config.id, 456) + + def test_create_config_without_devices_raises_error(self): + """ + Tests that config_create raises ValueError when no devices, disks, or volumes are specified. + """ + linode = Instance(self.client, 123) + + with self.assertRaises(ValueError) as context: + linode.config_create(label="test-config") + + assert "Must include at least one disk or volume!" in str( + context.exception + ) + def test_get_placement_group(self): """ Tests that you can get the placement group for a Linode