Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
121 changes: 121 additions & 0 deletions tests/unit_tests/long_term/test_long_term.py
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions tests/unit_tests/volumes/test_volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions verda/_verda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
1 change: 1 addition & 0 deletions verda/long_term/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from ._long_term import LongTermPeriod, LongTermService
43 changes: 43 additions & 0 deletions verda/long_term/_long_term.py
Original file line number Diff line number Diff line change
@@ -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]
12 changes: 12 additions & 0 deletions verda/volumes/_volumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down