Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 1 addition & 2 deletions CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
* @linode/dx

* @linode/dx @linode/dx-sdets
149 changes: 97 additions & 52 deletions linode_api4/objects/linode.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import copy
import string
import sys
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -1263,17 +1269,22 @@ 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
"""
# 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(
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions linode_api4/objects/lke.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Base,
DerivedBase,
Instance,
InstanceDiskEncryptionType,
JSONObject,
MappedObject,
Property,
Expand Down Expand Up @@ -422,6 +423,9 @@ def node_pool_create(
] = None,
update_strategy: Optional[str] = None,
label: str = None,
disk_encryption: Optional[
Union[str, InstanceDiskEncryptionType]
] = None,
**kwargs,
):
"""
Expand All @@ -443,6 +447,9 @@ def node_pool_create(
:param update_strategy: The strategy to use when updating this node pool.
NOTE: This field is specific to enterprise clusters.
:type update_strategy: str
:param disk_encryption: Local disk encryption setting for this LKE node pool.
One of 'enabled' or 'disabled'. Defaults to 'disabled'.
:type disk_encryption: str or InstanceDiskEncryptionType
:param kwargs: Any other arguments to pass to the API. See the API docs
for possible values.

Expand All @@ -459,6 +466,7 @@ def node_pool_create(
"taints": taints,
"k8s_version": k8s_version,
"update_strategy": update_strategy,
"disk_encryption": disk_encryption,
}
params.update(kwargs)

Expand Down
9 changes: 8 additions & 1 deletion linode_api4/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion test/integration/models/database/test_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ def test_update_sql_db(test_linode_client, test_create_sql_db):

assert res
assert database.allow_list == new_allow_list
# Label assertion is commented out because the API updates
# the label intermittently, causing test failures. The issue
# is tracked in TPT-4268.
# assert database.label == label
assert database.updates.day_of_week == 2

Expand Down Expand Up @@ -354,7 +357,10 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db):

assert res
assert database.allow_list == new_allow_list
assert database.label == label
# Label assertion is commented out because the API updates
# the label intermittently, causing test failures. The issue
# is tracked in TPT-4268.
# assert database.label == label
assert database.updates.day_of_week == 2


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_get_mysql_config(test_linode_client):
assert isinstance(brp, dict)
assert brp["type"] == "integer"
assert brp["minimum"] == 600
assert brp["maximum"] == 86400
assert brp["maximum"] == 9007199254740991
assert brp["requires_restart"] is False

# mysql sub-keys
Expand Down
9 changes: 9 additions & 0 deletions test/integration/models/linode/test_linode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
15 changes: 15 additions & 0 deletions test/integration/models/lke/test_lke.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]:
)


def test_node_pool_create_with_disk_encryption(test_linode_client, lke_cluster):
node_type = test_linode_client.linode.types()[1]

pool = lke_cluster.node_pool_create(
node_type,
1,
disk_encryption=InstanceDiskEncryptionType.enabled,
)

try:
assert pool.disk_encryption == InstanceDiskEncryptionType.enabled
finally:
pool.delete()


def test_cluster_dashboard_url_view(lke_cluster):
cluster = lke_cluster

Expand Down
Loading
Loading