From 362abdcb751a6271734f54f93a433e53ce4f2de9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:27:42 -0500 Subject: [PATCH 1/7] build(deps): bump crazy-max/ghaction-github-labeler from 5.3.0 to 6.0.0 (#659) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.3.0 to 6.0.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/24d110aa46a59976b8a7f35518cb7f14f434c916...548a7c3603594ec17c819e1239f281a3b801ab4d) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 843a41c4d..14e770b11 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v6 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml From bc5f7350247d7b3b06e9de8ac51ba8fcbeb18900 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:15:57 -0500 Subject: [PATCH 2/7] build(deps): bump actions/download-artifact from 7 to 8 (#657) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 5c24361d0..df1a41841 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -193,7 +193,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: test-report-file From 34fbbab7e8bc1dd5469e31e1d34202306c2136f0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:58:32 -0500 Subject: [PATCH 3/7] Migrate from os.path to pathlib for file path handling (#654) * Migrate from os.path to pathlib for file path handling in multiple modules * fix --- docs/conf.py | 6 +++--- linode_api4/common.py | 8 ++++---- linode_api4/groups/linode.py | 7 ++++--- linode_api4/groups/profile.py | 8 ++++---- linode_api4/objects/nodebalancer.py | 12 +++++++----- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd15307ac..ee6609943 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,11 +10,11 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# documentation root, use Path(...).absolute() to make it absolute, like shown here. # -import os import sys -sys.path.insert(0, os.path.abspath('..')) +from pathlib import Path +sys.path.insert(0, str(Path('..').absolute())) # -- Project information ----------------------------------------------------- diff --git a/linode_api4/common.py b/linode_api4/common.py index 7e98b1977..ac77d2a05 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,5 +1,5 @@ -import os from dataclasses import dataclass +from pathlib import Path from linode_api4.objects import JSONObject @@ -47,9 +47,9 @@ def load_and_validate_keys(authorized_keys): ret.append(k) else: # it doesn't appear to be a key.. is it a path to the key? - k = os.path.expanduser(k) - if os.path.isfile(k): - with open(k) as f: + k_path = Path(k).expanduser() + if k_path.is_file(): + with open(k_path) as f: ret.append(f.read().rstrip()) else: raise ValueError( diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index e32a284f1..2bd51fa97 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,5 +1,5 @@ import base64 -import os +from pathlib import Path from typing import Any, Dict, List, Optional, Union from linode_api4.common import load_and_validate_keys @@ -457,8 +457,9 @@ def stackscript_create( script_body = script if not script.startswith("#!"): # it doesn't look like a stackscript body, let's see if it's a file - if os.path.isfile(script): - with open(script) as f: + script_path = Path(script) + if script_path.is_file(): + with open(script_path) as f: script_body = f.read() else: raise ValueError( diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 4c49a2b5a..ee583a1ac 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -1,5 +1,5 @@ -import os from datetime import datetime +from pathlib import Path from linode_api4 import UnexpectedResponseError from linode_api4.common import SSH_KEY_TYPES @@ -322,9 +322,9 @@ def ssh_key_upload(self, key, label): """ if not key.startswith(SSH_KEY_TYPES): # this might be a file path - look for it - path = os.path.expanduser(key) - if os.path.isfile(path): - with open(path) as f: + key_path = Path(key).expanduser() + if key_path.is_file(): + with open(key_path) as f: key = f.read().strip() if not key.startswith(SSH_KEY_TYPES): raise ValueError("Invalid SSH Public Key") diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index cb6e566f7..f70553295 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from urllib import parse from linode_api4.common import Price, RegionPrice @@ -220,12 +220,14 @@ def load_ssl_data(self, cert_file, key_file): # we're disabling warnings here because these attributes are defined dynamically # through linode.objects.Base, and pylint isn't privy - if os.path.isfile(os.path.expanduser(cert_file)): - with open(os.path.expanduser(cert_file)) as f: + cert_path = Path(cert_file).expanduser() + if cert_path.is_file(): + with open(cert_path) as f: self.ssl_cert = f.read() - if os.path.isfile(os.path.expanduser(key_file)): - with open(os.path.expanduser(key_file)) as f: + key_path = Path(key_file).expanduser() + if key_path.is_file(): + with open(key_path) as f: self.ssl_key = f.read() From 26e964f7eccf9688e51348c0bb475f8a188ff5e8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:32:02 -0400 Subject: [PATCH 4/7] project: Block Storage Volume Limit Increase (#635) * Support increased block storage volume limits * Fixed lint * Address CoPilot suggestions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address more Copilot suggestions * Fix lint * Addressed PR comments * test (#629) --------- Co-authored-by: ezilber-akamai Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Vinay <143587840+vshanthe@users.noreply.github.com> Co-authored-by: Dawid <56120592+dawiddzhafarov@users.noreply.github.com> --- linode_api4/objects/linode.py | 17 ++- linode_api4/util.py | 26 ++++ .../models/volume/test_blockstorage.py | 40 ++++++ test/unit/util_test.py | 121 +++++++++++++++++- 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 test/integration/models/volume/test_blockstorage.py diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index ccddd7e40..3ffe4b232 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -40,9 +40,12 @@ 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 +from linode_api4.util import drop_null_keys, generate_device_suffixes PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +MIN_DEVICE_LIMIT = 8 +MB_PER_GB = 1024 +MAX_DEVICE_LIMIT = 64 class InstanceDiskEncryptionType(StrEnum): @@ -1272,9 +1275,19 @@ def config_create( from .volume import Volume # pylint: disable=import-outside-toplevel hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" + + device_limit = int( + max( + MIN_DEVICE_LIMIT, + min(self.specs.memory // MB_PER_GB, MAX_DEVICE_LIMIT), + ) + ) + device_names = [ - hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8) + hypervisor_prefix + suffix + for suffix in generate_device_suffixes(device_limit) ] + device_map = { device_names[i]: None for i in range(0, len(device_names)) } diff --git a/linode_api4/util.py b/linode_api4/util.py index 1ddbcc25b..f661367af 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -2,6 +2,7 @@ Contains various utility functions. """ +import string from typing import Any, Dict @@ -27,3 +28,28 @@ def recursive_helper(value: Any) -> Any: return value return recursive_helper(data) + + +def generate_device_suffixes(n: int) -> list[str]: + """ + Generate n alphabetical suffixes starting with a, b, c, etc. + After z, continue with aa, ab, ac, etc. followed by aaa, aab, etc. + Example: + generate_device_suffixes(30) -> + ['a', 'b', 'c', ..., 'z', 'aa', 'ab', 'ac', 'ad'] + """ + letters = string.ascii_lowercase + result = [] + i = 0 + + while len(result) < n: + s = "" + x = i + while True: + s = letters[x % 26] + s + x = x // 26 - 1 + if x < 0: + break + result.append(s) + i += 1 + return result diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py new file mode 100644 index 000000000..8dac88e18 --- /dev/null +++ b/test/integration/models/volume/test_blockstorage.py @@ -0,0 +1,40 @@ +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label, retry_sending_request + + +def test_config_create_with_extended_volume_limit(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, + ) + + volumes = [ + client.volume_create( + f"{label}-vol-{i}", + region=region, + size=10, + ) + for i in range(12) + ] + + config = linode.config_create(volumes=volumes) + + devices = config._raw_json["devices"] + + assert len([d for d in devices.values() if d is not None]) == 12 + + assert "sdi" in devices + assert "sdj" in devices + assert "sdk" in devices + assert "sdl" in devices + + linode.delete() + for v in volumes: + retry_sending_request(3, v.delete) diff --git a/test/unit/util_test.py b/test/unit/util_test.py index 3123a4447..35adf38ff 100644 --- a/test/unit/util_test.py +++ b/test/unit/util_test.py @@ -1,6 +1,6 @@ import unittest -from linode_api4.util import drop_null_keys +from linode_api4.util import drop_null_keys, generate_device_suffixes class UtilTest(unittest.TestCase): @@ -53,3 +53,122 @@ def test_drop_null_keys_recursive(self): } assert drop_null_keys(value) == expected_output + + def test_generate_device_suffixes(self): + """ + Tests whether generate_device_suffixes works as expected. + """ + + expected_output_12 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + ] + assert generate_device_suffixes(12) == expected_output_12 + + expected_output_30 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + ] + assert generate_device_suffixes(30) == expected_output_30 + + expected_output_60 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + "ae", + "af", + "ag", + "ah", + "ai", + "aj", + "ak", + "al", + "am", + "an", + "ao", + "ap", + "aq", + "ar", + "as", + "at", + "au", + "av", + "aw", + "ax", + "ay", + "az", + "ba", + "bb", + "bc", + "bd", + "be", + "bf", + "bg", + "bh", + ] + assert generate_device_suffixes(60) == expected_output_60 From d18b54e1cf6bebd31f850c028b6646fb502de63f Mon Sep 17 00:00:00 2001 From: Dawid <56120592+dawiddzhafarov@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:20:42 +0100 Subject: [PATCH 5/7] Drop support for ScaleGrid databases (#649) --- linode_api4/objects/database.py | 115 ------------------ ...databases_mysql_instances_123_backups.json | 13 -- ...sql_instances_123_backups_456_restore.json | 1 - ...ases_postgresql_instances_123_backups.json | 13 -- ...sql_instances_123_backups_456_restore.json | 1 - test/unit/objects/database_test.py | 115 ------------------ 6 files changed, 258 deletions(-) delete mode 100644 test/fixtures/databases_mysql_instances_123_backups.json delete mode 100644 test/fixtures/databases_mysql_instances_123_backups_456_restore.json delete mode 100644 test/fixtures/databases_postgresql_instances_123_backups.json delete mode 100644 test/fixtures/databases_postgresql_instances_123_backups_456_restore.json diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 979990e8e..b3c6f8c35 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,11 +1,8 @@ from dataclasses import dataclass, field from typing import Optional -from deprecated import deprecated - from linode_api4.objects import ( Base, - DerivedBase, JSONObject, MappedObject, Property, @@ -86,69 +83,6 @@ class DatabasePrivateNetwork(JSONObject): public_access: Optional[bool] = None -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class DatabaseBackup(DerivedBase): - """ - A generic Managed Database backup. - - This class is not intended to be used on its own. - Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup) - """ - - api_endpoint = "" - derived_url_path = "backups" - parent_id_name = "database_id" - - properties = { - "created": Property(is_datetime=True), - "id": Property(identifier=True), - "label": Property(), - "type": Property(), - } - - def restore(self): - """ - Restore a backup to a Managed Database on your Account. - - API Documentation: - - - MySQL: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup-restore - - PostgreSQL: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup-restore - """ - - return self._client.post( - "{}/restore".format(self.api_endpoint), model=self - ) - - -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class MySQLDatabaseBackup(DatabaseBackup): - """ - A backup for an accessible Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-backup - """ - - api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" - - -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class PostgreSQLDatabaseBackup(DatabaseBackup): - """ - A backup for an accessible Managed PostgreSQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-instance-backup - """ - - api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" - - @dataclass class MySQLDatabaseConfigMySQLOptions(JSONObject): """ @@ -296,7 +230,6 @@ class MySQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=MySQLDatabaseBackup), "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), @@ -304,7 +237,6 @@ class MySQLDatabase(Base): "hosts": Property(), "port": Property(), "region": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), "type": Property(mutable=True), @@ -393,28 +325,6 @@ def patch(self): "{}/patch".format(MySQLDatabase.api_endpoint), model=self ) - @deprecated( - reason="Backups are not supported for non-legacy database clusters." - ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(MySQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. @@ -464,7 +374,6 @@ class PostgreSQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=PostgreSQLDatabaseBackup), "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), @@ -472,8 +381,6 @@ class PostgreSQLDatabase(Base): "hosts": Property(), "port": Property(), "region": Property(), - "replication_commit_type": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), "type": Property(mutable=True), @@ -563,28 +470,6 @@ def patch(self): "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self ) - @deprecated( - reason="Backups are not supported for non-legacy database clusters." - ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed PostgreSQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(PostgreSQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. diff --git a/test/fixtures/databases_mysql_instances_123_backups.json b/test/fixtures/databases_mysql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_mysql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json b/test/fixtures/databases_mysql_instances_123_backups_456_restore.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups.json b/test/fixtures/databases_postgresql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_postgresql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json b/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 10cb8fc78..3d0eb4dad 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -116,63 +116,6 @@ def test_update(self): m.call_data["private_network"]["public_access"], True ) - def test_list_backups(self): - """ - Test that MySQL backups list properly - """ - - db = MySQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that MySQL database backups can be updated - """ - - with self.mock_post("/databases/mysql/instances/123/backups") as m: - db = MySQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="standby") - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups" - ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "standby") - - def test_backup_restore(self): - """ - Test that MySQL database backups can be restored - """ - - with self.mock_post( - "/databases/mysql/instances/123/backups/456/restore" - ) as m: - db = MySQLDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups/456/restore" - ) - def test_patch(self): """ Test MySQL Database patching logic. @@ -383,64 +326,6 @@ def test_update(self): m.call_data["private_network"]["public_access"], True ) - def test_list_backups(self): - """ - Test that PostgreSQL backups list properly - """ - - db = PostgreSQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that PostgreSQL database backups can be created - """ - - with self.mock_post("/databases/postgresql/instances/123/backups") as m: - db = PostgreSQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="standby") - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/postgresql/instances/123/backups" - ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "standby") - - def test_backup_restore(self): - """ - Test that PostgreSQL database backups can be restored - """ - - with self.mock_post( - "/databases/postgresql/instances/123/backups/456/restore" - ) as m: - db = PostgreSQLDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, - "/databases/postgresql/instances/123/backups/456/restore", - ) - def test_patch(self): """ Test PostgreSQL Database patching logic. From fa417d50d0d10c700c1322ea2684cb8f20225203 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:15:35 +0100 Subject: [PATCH 6/7] python-sdk: Support regions/vpc-availability endpoints (#646) --- linode_api4/groups/region.py | 36 ++++- linode_api4/objects/region.py | 38 +++++ .../regions_us-east_vpc-availability.json | 5 + test/fixtures/regions_vpc-availability.json | 132 ++++++++++++++++++ test/integration/models/region/test_region.py | 62 ++++++++ test/unit/groups/region_test.py | 30 +++- test/unit/objects/region_test.py | 12 ++ 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/regions_us-east_vpc-availability.json create mode 100644 test/fixtures/regions_vpc-availability.json create mode 100644 test/integration/models/region/test_region.py diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index baf8697e4..54bb37f0d 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -1,6 +1,9 @@ from linode_api4.groups import Group from linode_api4.objects import Region -from linode_api4.objects.region import RegionAvailabilityEntry +from linode_api4.objects.region import ( + RegionAvailabilityEntry, + RegionVPCAvailability, +) class RegionGroup(Group): @@ -43,3 +46,34 @@ def availability(self, *filters): return self.client._get_and_filter( RegionAvailabilityEntry, *filters, endpoint="/regions/availability" ) + + def vpc_availability(self, *filters): + """ + Returns VPC availability data for all regions. + + NOTE: IPv6 VPCs may not currently be available to all users. + + This endpoint supports pagination with the following parameters: + - page: Page number (>= 1) + - page_size: Number of items per page (25-500) + + Pagination is handled automatically by PaginatedList. To configure page_size, + set it when creating the LinodeClient: + + client = LinodeClient(token, page_size=100) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of VPC availability data for regions. + :rtype: PaginatedList of RegionVPCAvailability + """ + + return self.client._get_and_filter( + RegionVPCAvailability, + *filters, + endpoint="/regions/vpc-availability", + ) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 3c8986259..9a77dc485 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -125,6 +125,29 @@ def availability(self) -> List["RegionAvailabilityEntry"]: return [RegionAvailabilityEntry.from_json(v) for v in result] + @property + def vpc_availability(self) -> "RegionVPCAvailability": + """ + Returns VPC availability data for this region. + + NOTE: IPv6 VPCs may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-vpc-availability + + :returns: VPC availability data for this region. + :rtype: RegionVPCAvailability + """ + result = self._client.get( + f"{self.api_endpoint}/vpc-availability", model=self + ) + + if result is None: + raise UnexpectedResponseError( + "Expected VPC availability data, got None." + ) + + return RegionVPCAvailability.from_json(result) + @dataclass class RegionAvailabilityEntry(JSONObject): @@ -137,3 +160,18 @@ class RegionAvailabilityEntry(JSONObject): region: Optional[str] = None plan: Optional[str] = None available: bool = False + + +@dataclass +class RegionVPCAvailability(JSONObject): + """ + Represents the VPC availability data for a region. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + NOTE: IPv6 VPCs may not currently be available to all users. + """ + + region: Optional[str] = None + available: bool = False + available_ipv6_prefix_lengths: Optional[List[int]] = None diff --git a/test/fixtures/regions_us-east_vpc-availability.json b/test/fixtures/regions_us-east_vpc-availability.json new file mode 100644 index 000000000..209959e5d --- /dev/null +++ b/test/fixtures/regions_us-east_vpc-availability.json @@ -0,0 +1,5 @@ +{ + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] +} diff --git a/test/fixtures/regions_vpc-availability.json b/test/fixtures/regions_vpc-availability.json new file mode 100644 index 000000000..5e4d386df --- /dev/null +++ b/test/fixtures/regions_vpc-availability.json @@ -0,0 +1,132 @@ +{ + "data": [ + { + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] + }, + { + "region": "us-west", + "available": true, + "available_ipv6_prefix_lengths": [56, 52, 48] + }, + { + "region": "nl-ams", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-ord", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-iad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-sea", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "br-gru", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "se-sto", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "es-mad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-maa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-osa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "it-mil", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-mia", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "id-cgk", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-lax", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "gb-lon", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "au-mel", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-bom-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "de-fra-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "sg-sin-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-tyo-3", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ca-central", + "available": false, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ap-southeast", + "available": false, + "available_ipv6_prefix_lengths": [] + } + ], + "page": 1, + "pages": 2, + "results": 50 +} diff --git a/test/integration/models/region/test_region.py b/test/integration/models/region/test_region.py new file mode 100644 index 000000000..d9d4006a7 --- /dev/null +++ b/test/integration/models/region/test_region.py @@ -0,0 +1,62 @@ +import pytest + +from linode_api4.objects import Region + + +@pytest.mark.smoke +def test_list_regions_vpc_availability(test_linode_client): + """ + Test listing VPC availability for all regions. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + assert len(vpc_availability) > 0 + + for entry in vpc_availability: + assert entry.region is not None + assert len(entry.region) > 0 + assert entry.available is not None + assert isinstance(entry.available, bool) + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + assert isinstance(entry.available_ipv6_prefix_lengths, list) + + +@pytest.mark.smoke +def test_get_region_vpc_availability_via_object(test_linode_client): + """ + Test getting VPC availability via the Region object property. + """ + client = test_linode_client + + # Get the first available region + regions = client.regions() + assert len(regions) > 0 + test_region_id = regions[0].id + + region = Region(client, test_region_id) + vpc_avail = region.vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == test_region_id + assert vpc_avail.available is not None + assert isinstance(vpc_avail.available, bool) + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) + + +def test_vpc_availability_available_regions(test_linode_client): + """ + Test that some regions have VPC availability enabled. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + # Filter for regions where VPC is available + available_regions = [v for v in vpc_availability if v.available] + + # There should be at least some regions with VPC available + assert len(available_regions) > 0 diff --git a/test/unit/groups/region_test.py b/test/unit/groups/region_test.py index fe44c13ab..35826c534 100644 --- a/test/unit/groups/region_test.py +++ b/test/unit/groups/region_test.py @@ -25,10 +25,7 @@ def test_list_availability(self): for entry in avail_entries: assert entry.region is not None assert len(entry.region) > 0 - - assert entry.plan is not None assert len(entry.plan) > 0 - assert entry.available is not None # Ensure all three pages are read @@ -49,3 +46,30 @@ def test_list_availability(self): assert json.loads(call.get("headers").get("X-Filter")) == { "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] } + + def test_list_vpc_availability(self): + """ + Tests that region VPC availability can be listed. + """ + + with self.mock_get("/regions/vpc-availability") as m: + vpc_entries = self.client.regions.vpc_availability() + + assert len(vpc_entries) > 0 + + for entry in vpc_entries: + assert len(entry.region) > 0 + assert entry.available is not None + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + + # Ensure both pages are read + assert m.call_count == 2 + assert ( + m.mock.call_args_list[0].args[0] == "//regions/vpc-availability" + ) + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/vpc-availability?page=2&page_size=25" + ) diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 73fdc8f5d..7bc3ae9f8 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -49,3 +49,15 @@ def test_region_availability(self): assert len(entry.plan) > 0 assert entry.available is not None + + def test_region_vpc_availability(self): + """ + Tests that VPC availability for a specific region can be retrieved. + """ + vpc_avail = Region(self.client, "us-east").vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == "us-east" + assert vpc_avail.available is True + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) From cdfcd3dc821e94a5580e7b2b08d2c7a458889a18 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:36:06 -0400 Subject: [PATCH 7/7] Fix invalid error assertion in VPC integration tests (#667) --- test/integration/models/vpc/test_vpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index ee35929b0..85d32d858 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -105,7 +105,7 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) + assert "Must only use ASCII" in str(excinfo.value.json) def test_fails_create_subnet_with_invalid_ipv6_range(create_vpc):