From 19ec59db3030526c1810d8b9e2e63481dd0b7e7f Mon Sep 17 00:00:00 2001 From: lei Date: Wed, 25 Mar 2026 16:26:01 +0200 Subject: [PATCH] feat: sync SDK with latest OpenAPI spec 2026-03-25 - Add LongTermService with get_cluster_periods() and get_instance_periods() - Add VolumesService.delete_by_id() using DELETE /v1/volumes/{volume_id} - Wire LongTermService into VerdaClient - Add unit tests for all new methods --- CHANGELOG.md | 5 + tests/unit_tests/long_term/__init__.py | 0 tests/unit_tests/long_term/test_long_term.py | 121 +++++++++++++++++++ tests/unit_tests/volumes/test_volumes.py | 57 +++++++++ verda/_verda.py | 4 + verda/long_term/__init__.py | 1 + verda/long_term/_long_term.py | 43 +++++++ verda/volumes/_volumes.py | 12 ++ 8 files changed, 243 insertions(+) create mode 100644 tests/unit_tests/long_term/__init__.py create mode 100644 tests/unit_tests/long_term/test_long_term.py create mode 100644 verda/long_term/__init__.py create mode 100644 verda/long_term/_long_term.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e7384e6..426eb62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `LongTermService` with `get_cluster_periods()` and `get_instance_periods()` methods +- `VolumesService.delete_by_id()` method using `DELETE /v1/volumes/{volume_id}` endpoint + ## [1.22.0] - 2026-03-20 ### Added diff --git a/tests/unit_tests/long_term/__init__.py b/tests/unit_tests/long_term/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/long_term/test_long_term.py b/tests/unit_tests/long_term/test_long_term.py new file mode 100644 index 0000000..3238d5a --- /dev/null +++ b/tests/unit_tests/long_term/test_long_term.py @@ -0,0 +1,121 @@ +import pytest +import responses + +from verda.exceptions import APIException +from verda.long_term import LongTermPeriod, LongTermService + +INVALID_REQUEST = 'invalid_request' +INVALID_REQUEST_MESSAGE = 'Bad request' + +PERIOD_1 = { + 'code': '3_MONTHS', + 'name': '3 months', + 'is_enabled': True, + 'unit_name': 'month', + 'unit_value': 3.0, + 'discount_percentage': 14.0, +} + +PERIOD_2 = { + 'code': '6_MONTHS', + 'name': '6 months', + 'is_enabled': True, + 'unit_name': 'month', + 'unit_value': 6.0, + 'discount_percentage': 20.0, +} + +PAYLOAD = [PERIOD_1, PERIOD_2] + + +class TestLongTermService: + @pytest.fixture + def long_term_service(self, http_client): + return LongTermService(http_client) + + @pytest.fixture + def endpoint(self, http_client): + return http_client._base_url + '/long-term/periods' + + def test_get_cluster_periods(self, long_term_service, endpoint): + # arrange + responses.add(responses.GET, endpoint + '/clusters', json=PAYLOAD, status=200) + + # act + periods = long_term_service.get_cluster_periods() + + # assert + assert isinstance(periods, list) + assert len(periods) == 2 + assert isinstance(periods[0], LongTermPeriod) + assert periods[0].code == '3_MONTHS' + assert periods[0].name == '3 months' + assert periods[0].is_enabled is True + assert periods[0].unit_name == 'month' + assert periods[0].unit_value == 3.0 + assert periods[0].discount_percentage == 14.0 + assert periods[1].code == '6_MONTHS' + assert responses.assert_call_count(endpoint + '/clusters', 1) is True + + def test_get_cluster_periods_failed(self, long_term_service, endpoint): + # arrange + url = endpoint + '/clusters' + responses.add( + responses.GET, + url, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + # act + assert + with pytest.raises(APIException) as excinfo: + long_term_service.get_cluster_periods() + + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + + def test_get_instance_periods(self, long_term_service, endpoint): + # arrange + responses.add(responses.GET, endpoint + '/instances', json=PAYLOAD, status=200) + + # act + periods = long_term_service.get_instance_periods() + + # assert + assert isinstance(periods, list) + assert len(periods) == 2 + assert isinstance(periods[0], LongTermPeriod) + assert periods[0].code == '3_MONTHS' + assert periods[0].discount_percentage == 14.0 + assert periods[1].unit_value == 6.0 + assert responses.assert_call_count(endpoint + '/instances', 1) is True + + def test_get_instance_periods_failed(self, long_term_service, endpoint): + # arrange + url = endpoint + '/instances' + responses.add( + responses.GET, + url, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + # act + assert + with pytest.raises(APIException) as excinfo: + long_term_service.get_instance_periods() + + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + + def test_get_cluster_periods_empty_list(self, long_term_service, endpoint): + # arrange + responses.add(responses.GET, endpoint + '/clusters', json=[], status=200) + + # act + periods = long_term_service.get_cluster_periods() + + # assert + assert isinstance(periods, list) + assert len(periods) == 0 diff --git a/tests/unit_tests/volumes/test_volumes.py b/tests/unit_tests/volumes/test_volumes.py index e573c4d..497e101 100644 --- a/tests/unit_tests/volumes/test_volumes.py +++ b/tests/unit_tests/volumes/test_volumes.py @@ -506,6 +506,63 @@ def test_delete_volume_failed(self, volumes_service, endpoint): assert excinfo.value.message == INVALID_REQUEST_MESSAGE assert responses.assert_call_count(endpoint, 1) is True + def test_delete_volume_by_id_successful(self, volumes_service, endpoint): + # arrange + url = endpoint + '/' + NVME_VOL_ID + responses.add( + responses.DELETE, + url, + status=202, + match=[ + matchers.json_params_matcher({'is_permanent': False}) + ], + ) + + # act + result = volumes_service.delete_by_id(NVME_VOL_ID) + + # assert + assert result is None + assert responses.assert_call_count(url, 1) is True + + def test_delete_volume_by_id_permanent_successful(self, volumes_service, endpoint): + # arrange + url = endpoint + '/' + NVME_VOL_ID + responses.add( + responses.DELETE, + url, + status=202, + match=[ + matchers.json_params_matcher({'is_permanent': True}) + ], + ) + + # act + result = volumes_service.delete_by_id(NVME_VOL_ID, is_permanent=True) + + # assert + assert result is None + assert responses.assert_call_count(url, 1) is True + + def test_delete_volume_by_id_failed(self, volumes_service, endpoint): + # arrange + url = endpoint + '/' + NVME_VOL_ID + responses.add( + responses.DELETE, + url, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + # act + with pytest.raises(APIException) as excinfo: + volumes_service.delete_by_id(NVME_VOL_ID) + + # assert + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + def test_clone_volume_with_input_name_successful(self, volumes_service, endpoint): # arrange CLONED_VOLUME_NAME = 'cloned-volume' diff --git a/verda/_verda.py b/verda/_verda.py index 544b775..2d9b85c 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -8,6 +8,7 @@ from verda.containers import ContainersService from verda.http_client import HTTPClient from verda.images import ImagesService +from verda.long_term import LongTermService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService from verda.job_deployments import JobDeploymentsService @@ -95,5 +96,8 @@ def __init__( self.cluster_types: ClusterTypesService = ClusterTypesService(self._http_client) """Cluster types service. Get available cluster info""" + self.long_term: LongTermService = LongTermService(self._http_client) + """Long-term service. Get available commitment periods""" + __all__ = ['VerdaClient'] diff --git a/verda/long_term/__init__.py b/verda/long_term/__init__.py new file mode 100644 index 0000000..7414fcb --- /dev/null +++ b/verda/long_term/__init__.py @@ -0,0 +1 @@ +from ._long_term import LongTermPeriod, LongTermService diff --git a/verda/long_term/_long_term.py b/verda/long_term/_long_term.py new file mode 100644 index 0000000..5f588fa --- /dev/null +++ b/verda/long_term/_long_term.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + +from dataclasses_json import Undefined, dataclass_json + +LONG_TERM_PERIODS_ENDPOINT = '/long-term/periods' + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class LongTermPeriod: + """A long-term commitment period.""" + + code: str + name: str + is_enabled: bool + unit_name: str + unit_value: float + discount_percentage: float + + +class LongTermService: + """A service for interacting with the long-term periods endpoints.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get_cluster_periods(self) -> list[LongTermPeriod]: + """Get available long-term commitment periods for clusters. + + :return: list of long-term period objects + :rtype: list[LongTermPeriod] + """ + periods = self._http_client.get(LONG_TERM_PERIODS_ENDPOINT + '/clusters').json() + return [LongTermPeriod.from_dict(p, infer_missing=True) for p in periods] + + def get_instance_periods(self) -> list[LongTermPeriod]: + """Get available long-term commitment periods for instances. + + :return: list of long-term period objects + :rtype: list[LongTermPeriod] + """ + periods = self._http_client.get(LONG_TERM_PERIODS_ENDPOINT + '/instances').json() + return [LongTermPeriod.from_dict(p, infer_missing=True) for p in periods] diff --git a/verda/volumes/_volumes.py b/verda/volumes/_volumes.py index ca66573..981ac19 100644 --- a/verda/volumes/_volumes.py +++ b/verda/volumes/_volumes.py @@ -368,6 +368,18 @@ def increase_size(self, id_list: list[str] | str, size: int) -> None: self._http_client.put(VOLUMES_ENDPOINT, json=payload) return + def delete_by_id(self, volume_id: str, is_permanent: bool = False) -> None: + """Delete a single volume by id using the DELETE endpoint. + + :param volume_id: volume id + :type volume_id: str + :param is_permanent: if True, volume is removed permanently; if False, moves to trash + :type is_permanent: bool, optional + """ + payload = {'is_permanent': is_permanent} + self._http_client.delete(VOLUMES_ENDPOINT + f'/{volume_id}', json=payload) + return + def delete(self, id_list: list[str] | str, is_permanent: bool = False) -> None: """Delete multiple volumes or single volume.