From e5247624b5c216c37fe80ae9b54f6526bfb04ba9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:20:35 +0000 Subject: [PATCH 1/8] feat(container-types): add OpenAPI container types service Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 2 + datacrunch_compat/datacrunch/datacrunch.py | 2 + docs/source/api/services/container_types.rst | 8 +++ .../container_types/test_container_types.py | 49 ++++++++++++++++ tests/unit_tests/test_client.py | 4 ++ verda/_verda.py | 4 ++ verda/container_types/__init__.py | 3 + verda/container_types/_container_types.py | 58 +++++++++++++++++++ 8 files changed, 130 insertions(+) create mode 100644 docs/source/api/services/container_types.rst create mode 100644 tests/unit_tests/container_types/test_container_types.py create mode 100644 verda/container_types/__init__.py create mode 100644 verda/container_types/_container_types.py diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index 08d0de2..4fc88db 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -6,6 +6,7 @@ authentication, balance, constants, + container_types, containers, exceptions, helpers, @@ -29,6 +30,7 @@ 'authentication', 'balance', 'constants', + 'container_types', 'containers', 'datacrunch', 'exceptions', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index f14f0e3..aae5113 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -5,6 +5,7 @@ from verda.authentication import AuthenticationService from verda.balance import BalanceService from verda.constants import Constants +from verda.container_types import ContainerTypesService from verda.containers import ContainersService from verda.http_client import HTTPClient from verda.images import ImagesService @@ -21,6 +22,7 @@ 'AuthenticationService', 'BalanceService', 'Constants', + 'ContainerTypesService', 'ContainersService', 'DataCrunchClient', 'HTTPClient', diff --git a/docs/source/api/services/container_types.rst b/docs/source/api/services/container_types.rst new file mode 100644 index 0000000..a2d53c7 --- /dev/null +++ b/docs/source/api/services/container_types.rst @@ -0,0 +1,8 @@ +Container Types +=============== + +.. autoclass:: verda.container_types.ContainerTypesService + :members: + +.. autoclass:: verda.container_types.ContainerType + :members: diff --git a/tests/unit_tests/container_types/test_container_types.py b/tests/unit_tests/container_types/test_container_types.py new file mode 100644 index 0000000..fbe337b --- /dev/null +++ b/tests/unit_tests/container_types/test_container_types.py @@ -0,0 +1,49 @@ +import responses # https://github.com/getsentry/responses + +from verda.container_types import ContainerType, ContainerTypesService + +CONTAINER_TYPE_ID = 'type-c0de-a5d2-4972-ae4e-d429115d055b' + + +@responses.activate +def test_container_types(http_client): + endpoint = http_client._base_url + '/container-types?currency=eur' + responses.add( + responses.GET, + endpoint, + json=[ + { + 'id': CONTAINER_TYPE_ID, + 'model': 'H100', + 'name': 'H100 SXM5 80GB', + 'instance_type': '1H100.80S.22V', + 'cpu': {'description': '22 CPU', 'number_of_cores': 22}, + 'gpu': {'description': '1x H100 SXM5 80GB', 'number_of_gpus': 1}, + 'gpu_memory': {'description': '80GB GPU RAM', 'size_in_gigabytes': 80}, + 'memory': {'description': '187GB RAM', 'size_in_gigabytes': 187}, + 'serverless_price': '1.75', + 'serverless_spot_price': '0.87', + 'currency': 'eur', + 'manufacturer': 'NVIDIA', + } + ], + status=200, + ) + + service = ContainerTypesService(http_client) + + container_types = service.get(currency='eur') + container_type = container_types[0] + + assert isinstance(container_types, list) + assert len(container_types) == 1 + assert isinstance(container_type, ContainerType) + assert container_type.id == CONTAINER_TYPE_ID + assert container_type.model == 'H100' + assert container_type.name == 'H100 SXM5 80GB' + assert container_type.instance_type == '1H100.80S.22V' + assert container_type.serverless_price == 1.75 + assert container_type.serverless_spot_price == 0.87 + assert container_type.currency == 'eur' + assert container_type.manufacturer == 'NVIDIA' + assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 1296162..4902ea9 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -16,6 +16,7 @@ class TestVerdaClient: + @responses.activate def test_client(self): # arrange - add response mock responses.add(responses.POST, BASE_URL + '/oauth2/token', json=response_json, status=200) @@ -25,7 +26,9 @@ def test_client(self): # assert assert client.constants.base_url == BASE_URL + assert hasattr(client, 'container_types') + @responses.activate def test_client_with_default_base_url(self): # arrange - add response mock DEFAULT_BASE_URL = 'https://api.verda.com/v1' @@ -42,6 +45,7 @@ def test_client_with_default_base_url(self): # assert assert client.constants.base_url == DEFAULT_BASE_URL + @responses.activate def test_invalid_client_credentials(self): # arrange - add response mock responses.add( diff --git a/verda/_verda.py b/verda/_verda.py index 3a47831..834e7c5 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -2,6 +2,7 @@ from verda.authentication import AuthenticationService from verda.balance import BalanceService from verda.clusters import ClustersService +from verda.container_types import ContainerTypesService from verda.constants import Constants from verda.containers import ContainersService from verda.http_client import HTTPClient @@ -80,6 +81,9 @@ def __init__( self.containers: ContainersService = ContainersService(self._http_client, inference_key) """Containers service. Deploy, manage, and monitor container deployments""" + self.container_types: ContainerTypesService = ContainerTypesService(self._http_client) + """Container types service. Get available serverless container SKUs""" + self.clusters: ClustersService = ClustersService(self._http_client) """Clusters service. Create and manage compute clusters""" diff --git a/verda/container_types/__init__.py b/verda/container_types/__init__.py new file mode 100644 index 0000000..8b960ad --- /dev/null +++ b/verda/container_types/__init__.py @@ -0,0 +1,3 @@ +from verda.container_types._container_types import ContainerType, ContainerTypesService + +__all__ = ['ContainerType', 'ContainerTypesService'] diff --git a/verda/container_types/_container_types.py b/verda/container_types/_container_types.py new file mode 100644 index 0000000..81ec78b --- /dev/null +++ b/verda/container_types/_container_types.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import Literal + +from dataclasses_json import dataclass_json + +CONTAINER_TYPES_ENDPOINT = '/container-types' + +Currency = Literal['usd', 'eur'] + + +@dataclass_json +@dataclass +class ContainerType: + """Container type returned by the public API.""" + + id: str + model: str + name: str + instance_type: str + cpu: dict + gpu: dict + gpu_memory: dict + memory: dict + serverless_price: float + serverless_spot_price: float + currency: Currency + manufacturer: str + + +class ContainerTypesService: + """Service for interacting with container types.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get(self, currency: Currency = 'usd') -> list[ContainerType]: + """Return all available container types.""" + container_types = self._http_client.get( + CONTAINER_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() + return [ + ContainerType( + id=container_type['id'], + model=container_type['model'], + name=container_type['name'], + instance_type=container_type['instance_type'], + cpu=container_type['cpu'], + gpu=container_type['gpu'], + gpu_memory=container_type['gpu_memory'], + memory=container_type['memory'], + serverless_price=float(container_type['serverless_price']), + serverless_spot_price=float(container_type['serverless_spot_price']), + currency=container_type['currency'], + manufacturer=container_type['manufacturer'], + ) + for container_type in container_types + ] From 81827772f1e044c34dc7b47210a79b9ada2bc9ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:21:19 +0000 Subject: [PATCH 2/8] feat(cluster-types): add OpenAPI cluster types service Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 2 + datacrunch_compat/datacrunch/datacrunch.py | 2 + docs/source/api/services/cluster_types.rst | 8 +++ .../cluster_types/test_cluster_types.py | 51 ++++++++++++++++ tests/unit_tests/test_client.py | 1 + verda/_verda.py | 4 ++ verda/cluster_types/__init__.py | 3 + verda/cluster_types/_cluster_types.py | 60 +++++++++++++++++++ 8 files changed, 131 insertions(+) create mode 100644 docs/source/api/services/cluster_types.rst create mode 100644 tests/unit_tests/cluster_types/test_cluster_types.py create mode 100644 verda/cluster_types/__init__.py create mode 100644 verda/cluster_types/_cluster_types.py diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index 4fc88db..9f3a2eb 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -5,6 +5,7 @@ __version__, authentication, balance, + cluster_types, constants, container_types, containers, @@ -29,6 +30,7 @@ '__version__', 'authentication', 'balance', + 'cluster_types', 'constants', 'container_types', 'containers', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index aae5113..f73a418 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -4,6 +4,7 @@ from verda._version import __version__ from verda.authentication import AuthenticationService from verda.balance import BalanceService +from verda.cluster_types import ClusterTypesService from verda.constants import Constants from verda.container_types import ContainerTypesService from verda.containers import ContainersService @@ -21,6 +22,7 @@ __all__ = [ 'AuthenticationService', 'BalanceService', + 'ClusterTypesService', 'Constants', 'ContainerTypesService', 'ContainersService', diff --git a/docs/source/api/services/cluster_types.rst b/docs/source/api/services/cluster_types.rst new file mode 100644 index 0000000..a07a41a --- /dev/null +++ b/docs/source/api/services/cluster_types.rst @@ -0,0 +1,8 @@ +Cluster Types +============= + +.. autoclass:: verda.cluster_types.ClusterTypesService + :members: + +.. autoclass:: verda.cluster_types.ClusterType + :members: diff --git a/tests/unit_tests/cluster_types/test_cluster_types.py b/tests/unit_tests/cluster_types/test_cluster_types.py new file mode 100644 index 0000000..cea9e04 --- /dev/null +++ b/tests/unit_tests/cluster_types/test_cluster_types.py @@ -0,0 +1,51 @@ +import responses # https://github.com/getsentry/responses + +from verda.cluster_types import ClusterType, ClusterTypesService + +CLUSTER_TYPE_ID = 'cluster-c0de-a5d2-4972-ae4e-d429115d055b' + + +@responses.activate +def test_cluster_types(http_client): + endpoint = http_client._base_url + '/cluster-types?currency=usd' + responses.add( + responses.GET, + endpoint, + json=[ + { + 'id': CLUSTER_TYPE_ID, + 'model': 'H200', + 'name': 'H200 Cluster', + 'cluster_type': '16H200', + 'cpu': {'description': '64 CPU', 'number_of_cores': 64}, + 'gpu': {'description': '16x H200', 'number_of_gpus': 16}, + 'gpu_memory': {'description': '2.2TB VRAM', 'size_in_gigabytes': 2200}, + 'memory': {'description': '4TB RAM', 'size_in_gigabytes': 4096}, + 'price_per_hour': '45.50', + 'currency': 'usd', + 'manufacturer': 'NVIDIA', + 'node_details': ['2x 8 GPU nodes'], + 'supported_os': ['ubuntu-24.04-cuda-12.8-cluster'], + } + ], + status=200, + ) + + service = ClusterTypesService(http_client) + + cluster_types = service.get() + cluster_type = cluster_types[0] + + assert isinstance(cluster_types, list) + assert len(cluster_types) == 1 + assert isinstance(cluster_type, ClusterType) + assert cluster_type.id == CLUSTER_TYPE_ID + assert cluster_type.model == 'H200' + assert cluster_type.name == 'H200 Cluster' + assert cluster_type.cluster_type == '16H200' + assert cluster_type.price_per_hour == 45.5 + assert cluster_type.currency == 'usd' + assert cluster_type.manufacturer == 'NVIDIA' + assert cluster_type.node_details == ['2x 8 GPU nodes'] + assert cluster_type.supported_os == ['ubuntu-24.04-cuda-12.8-cluster'] + assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 4902ea9..a7a8be4 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -27,6 +27,7 @@ def test_client(self): # assert assert client.constants.base_url == BASE_URL assert hasattr(client, 'container_types') + assert hasattr(client, 'cluster_types') @responses.activate def test_client_with_default_base_url(self): diff --git a/verda/_verda.py b/verda/_verda.py index 834e7c5..aec3b32 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -1,6 +1,7 @@ from verda._version import __version__ from verda.authentication import AuthenticationService from verda.balance import BalanceService +from verda.cluster_types import ClusterTypesService from verda.clusters import ClustersService from verda.container_types import ContainerTypesService from verda.constants import Constants @@ -87,5 +88,8 @@ def __init__( self.clusters: ClustersService = ClustersService(self._http_client) """Clusters service. Create and manage compute clusters""" + self.cluster_types: ClusterTypesService = ClusterTypesService(self._http_client) + """Cluster types service. Get available cluster SKUs""" + __all__ = ['VerdaClient'] diff --git a/verda/cluster_types/__init__.py b/verda/cluster_types/__init__.py new file mode 100644 index 0000000..581deec --- /dev/null +++ b/verda/cluster_types/__init__.py @@ -0,0 +1,3 @@ +from verda.cluster_types._cluster_types import ClusterType, ClusterTypesService + +__all__ = ['ClusterType', 'ClusterTypesService'] diff --git a/verda/cluster_types/_cluster_types.py b/verda/cluster_types/_cluster_types.py new file mode 100644 index 0000000..806f4fa --- /dev/null +++ b/verda/cluster_types/_cluster_types.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Literal + +from dataclasses_json import dataclass_json + +CLUSTER_TYPES_ENDPOINT = '/cluster-types' + +Currency = Literal['usd', 'eur'] + + +@dataclass_json +@dataclass +class ClusterType: + """Cluster type returned by the public API.""" + + id: str + model: str + name: str + cluster_type: str + cpu: dict + gpu: dict + gpu_memory: dict + memory: dict + price_per_hour: float + currency: Currency + manufacturer: str + node_details: list[str] + supported_os: list[str] + + +class ClusterTypesService: + """Service for interacting with cluster types.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get(self, currency: Currency = 'usd') -> list[ClusterType]: + """Return all available cluster types.""" + cluster_types = self._http_client.get( + CLUSTER_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() + return [ + ClusterType( + id=cluster_type['id'], + model=cluster_type['model'], + name=cluster_type['name'], + cluster_type=cluster_type['cluster_type'], + cpu=cluster_type['cpu'], + gpu=cluster_type['gpu'], + gpu_memory=cluster_type['gpu_memory'], + memory=cluster_type['memory'], + price_per_hour=float(cluster_type['price_per_hour']), + currency=cluster_type['currency'], + manufacturer=cluster_type['manufacturer'], + node_details=cluster_type['node_details'], + supported_os=cluster_type['supported_os'], + ) + for cluster_type in cluster_types + ] From 50276b42c2388a43552f9163aa163c77dfedb108 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:22:03 +0000 Subject: [PATCH 3/8] feat(long-term): add OpenAPI long-term periods service Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 2 + datacrunch_compat/datacrunch/datacrunch.py | 2 + docs/source/api/services/long_term.rst | 8 +++ tests/unit_tests/long_term/test_long_term.py | 58 ++++++++++++++++++++ tests/unit_tests/test_client.py | 1 + verda/_verda.py | 4 ++ verda/long_term/__init__.py | 3 + verda/long_term/_long_term.py | 40 ++++++++++++++ 8 files changed, 118 insertions(+) create mode 100644 docs/source/api/services/long_term.rst 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/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index 9f3a2eb..ef00a25 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -15,6 +15,7 @@ images, instance_types, instances, + long_term, locations, ssh_keys, startup_scripts, @@ -41,6 +42,7 @@ 'images', 'instance_types', 'instances', + 'long_term', 'locations', 'ssh_keys', 'startup_scripts', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index f73a418..c394e84 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -12,6 +12,7 @@ from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.long_term import LongTermService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService @@ -31,6 +32,7 @@ 'ImagesService', 'InstanceTypesService', 'InstancesService', + 'LongTermService', 'LocationsService', 'SSHKeysService', 'StartupScriptsService', diff --git a/docs/source/api/services/long_term.rst b/docs/source/api/services/long_term.rst new file mode 100644 index 0000000..f15aa82 --- /dev/null +++ b/docs/source/api/services/long_term.rst @@ -0,0 +1,8 @@ +Long Term +========= + +.. autoclass:: verda.long_term.LongTermService + :members: + +.. autoclass:: verda.long_term.LongTermPeriod + :members: 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..92fa5eb --- /dev/null +++ b/tests/unit_tests/long_term/test_long_term.py @@ -0,0 +1,58 @@ +import responses # https://github.com/getsentry/responses + +from verda.long_term import LongTermPeriod, LongTermService + +PERIODS_PAYLOAD = [ + { + 'code': '1-month', + 'name': '1 Month', + 'is_enabled': True, + 'unit_name': 'month', + 'unit_value': 1, + 'discount_percentage': 5, + } +] + + +class TestLongTermService: + @responses.activate + def test_get_periods(self, http_client): + endpoint = http_client._base_url + '/long-term/periods' + responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) + + service = LongTermService(http_client) + + periods = service.get() + + assert isinstance(periods, list) + assert len(periods) == 1 + assert isinstance(periods[0], LongTermPeriod) + assert periods[0].code == '1-month' + assert periods[0].discount_percentage == 5 + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_get_instance_periods(self, http_client): + endpoint = http_client._base_url + '/long-term/periods/instances' + responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) + + service = LongTermService(http_client) + + periods = service.get_instances() + + assert len(periods) == 1 + assert periods[0].unit_name == 'month' + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_get_cluster_periods(self, http_client): + endpoint = http_client._base_url + '/long-term/periods/clusters' + responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) + + service = LongTermService(http_client) + + periods = service.get_clusters() + + assert len(periods) == 1 + assert periods[0].is_enabled is True + assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index a7a8be4..5d4f2e6 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -28,6 +28,7 @@ def test_client(self): assert client.constants.base_url == BASE_URL assert hasattr(client, 'container_types') assert hasattr(client, 'cluster_types') + assert hasattr(client, 'long_term') @responses.activate def test_client_with_default_base_url(self): diff --git a/verda/_verda.py b/verda/_verda.py index aec3b32..1a0f369 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -10,6 +10,7 @@ from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.long_term import LongTermService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService @@ -91,5 +92,8 @@ def __init__( self.cluster_types: ClusterTypesService = ClusterTypesService(self._http_client) """Cluster types service. Get available cluster SKUs""" + self.long_term: LongTermService = LongTermService(self._http_client) + """Long-term service. Get available long-term pricing periods""" + __all__ = ['VerdaClient'] diff --git a/verda/long_term/__init__.py b/verda/long_term/__init__.py new file mode 100644 index 0000000..7341a3a --- /dev/null +++ b/verda/long_term/__init__.py @@ -0,0 +1,3 @@ +from verda.long_term._long_term import LongTermPeriod, LongTermService + +__all__ = ['LongTermPeriod', 'LongTermService'] diff --git a/verda/long_term/_long_term.py b/verda/long_term/_long_term.py new file mode 100644 index 0000000..ff5e20d --- /dev/null +++ b/verda/long_term/_long_term.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +from dataclasses_json import dataclass_json + +LONG_TERM_PERIODS_ENDPOINT = '/long-term/periods' + + +@dataclass_json +@dataclass +class LongTermPeriod: + """Long-term pricing period returned by the public API.""" + + code: str + name: str + is_enabled: bool + unit_name: str + unit_value: float + discount_percentage: float + + +class LongTermService: + """Service for interacting with long-term pricing periods.""" + + def __init__(self, http_client) -> None: + self._http_client = http_client + + def get(self) -> list[LongTermPeriod]: + """Return all long-term periods.""" + periods = self._http_client.get(LONG_TERM_PERIODS_ENDPOINT).json() + return [LongTermPeriod.from_dict(period) for period in periods] + + def get_instances(self) -> list[LongTermPeriod]: + """Return long-term periods available for instances.""" + periods = self._http_client.get(f'{LONG_TERM_PERIODS_ENDPOINT}/instances').json() + return [LongTermPeriod.from_dict(period) for period in periods] + + def get_clusters(self) -> list[LongTermPeriod]: + """Return long-term periods available for clusters.""" + periods = self._http_client.get(f'{LONG_TERM_PERIODS_ENDPOINT}/clusters').json() + return [LongTermPeriod.from_dict(period) for period in periods] From 007e2a9b9d2a5a623ca3460affcc150af483a8ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:22:42 +0000 Subject: [PATCH 4/8] feat(job-deployments): add OpenAPI serverless jobs service Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 2 + datacrunch_compat/datacrunch/datacrunch.py | 2 + docs/source/api/services/job_deployments.rst | 14 ++ .../job_deployments/test_job_deployments.py | 209 ++++++++++++++++++ tests/unit_tests/test_client.py | 1 + verda/_verda.py | 4 + verda/job_deployments/__init__.py | 15 ++ verda/job_deployments/_job_deployments.py | 125 +++++++++++ 8 files changed, 372 insertions(+) create mode 100644 docs/source/api/services/job_deployments.rst create mode 100644 tests/unit_tests/job_deployments/test_job_deployments.py create mode 100644 verda/job_deployments/__init__.py create mode 100644 verda/job_deployments/_job_deployments.py diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index ef00a25..b981c58 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -15,6 +15,7 @@ images, instance_types, instances, + job_deployments, long_term, locations, ssh_keys, @@ -42,6 +43,7 @@ 'images', 'instance_types', 'instances', + 'job_deployments', 'long_term', 'locations', 'ssh_keys', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index c394e84..038797c 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -12,6 +12,7 @@ from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.job_deployments import JobDeploymentsService from verda.long_term import LongTermService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService @@ -32,6 +33,7 @@ 'ImagesService', 'InstanceTypesService', 'InstancesService', + 'JobDeploymentsService', 'LongTermService', 'LocationsService', 'SSHKeysService', diff --git a/docs/source/api/services/job_deployments.rst b/docs/source/api/services/job_deployments.rst new file mode 100644 index 0000000..2d06fbd --- /dev/null +++ b/docs/source/api/services/job_deployments.rst @@ -0,0 +1,14 @@ +Job Deployments +=============== + +.. autoclass:: verda.job_deployments.JobDeploymentsService + :members: + +.. autoclass:: verda.job_deployments.JobDeployment + :members: + +.. autoclass:: verda.job_deployments.JobDeploymentSummary + :members: + +.. autoclass:: verda.job_deployments.JobScalingOptions + :members: diff --git a/tests/unit_tests/job_deployments/test_job_deployments.py b/tests/unit_tests/job_deployments/test_job_deployments.py new file mode 100644 index 0000000..a265fe4 --- /dev/null +++ b/tests/unit_tests/job_deployments/test_job_deployments.py @@ -0,0 +1,209 @@ +import json + +import pytest +import responses # https://github.com/getsentry/responses + +from verda.containers import ComputeResource, Container, ContainerRegistrySettings +from verda.exceptions import APIException +from verda.job_deployments import ( + JobDeployment, + JobDeploymentsService, + JobDeploymentStatus, + JobDeploymentSummary, + JobScalingOptions, +) + +JOB_NAME = 'test-job' +CONTAINER_NAME = 'worker' +INVALID_REQUEST = 'INVALID_REQUEST' +INVALID_REQUEST_MESSAGE = 'Invalid request' + +JOB_SUMMARY_PAYLOAD = [ + { + 'name': JOB_NAME, + 'created_at': '2024-01-01T00:00:00Z', + 'compute': { + 'name': 'H100', + 'size': 1, + }, + } +] + +JOB_PAYLOAD = { + 'name': JOB_NAME, + 'containers': [ + { + 'name': CONTAINER_NAME, + 'image': 'busybox:latest', + 'exposed_port': 8080, + 'env': [], + 'volume_mounts': [], + } + ], + 'endpoint_base_url': 'https://test-job.datacrunch.io', + 'created_at': '2024-01-01T00:00:00Z', + 'compute': { + 'name': 'H100', + 'size': 1, + }, + 'container_registry_settings': { + 'is_private': False, + 'credentials': None, + }, +} + +SCALING_PAYLOAD = { + 'max_replica_count': 5, + 'queue_message_ttl_seconds': 600, + 'deadline_seconds': 1800, +} + + +class TestJobDeploymentsService: + @pytest.fixture + def service(self, http_client): + return JobDeploymentsService(http_client) + + @pytest.fixture + def endpoint(self, http_client): + return http_client._base_url + '/job-deployments' + + @responses.activate + def test_get_job_deployments(self, service, endpoint): + responses.add(responses.GET, endpoint, json=JOB_SUMMARY_PAYLOAD, status=200) + + deployments = service.get() + + assert isinstance(deployments, list) + assert len(deployments) == 1 + assert isinstance(deployments[0], JobDeploymentSummary) + assert deployments[0].name == JOB_NAME + assert deployments[0].compute.name == 'H100' + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_get_job_deployment_by_name(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}' + responses.add(responses.GET, url, json=JOB_PAYLOAD, status=200) + + deployment = service.get_by_name(JOB_NAME) + + assert isinstance(deployment, JobDeployment) + assert deployment.name == JOB_NAME + assert deployment.endpoint_base_url == 'https://test-job.datacrunch.io' + assert deployment.compute.size == 1 + assert deployment.containers[0].name == CONTAINER_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_deployment_by_name_error(self, service, endpoint): + url = f'{endpoint}/missing-job' + responses.add( + responses.GET, + url, + json={'code': INVALID_REQUEST, 'message': INVALID_REQUEST_MESSAGE}, + status=400, + ) + + with pytest.raises(APIException) as excinfo: + service.get_by_name('missing-job') + + assert excinfo.value.code == INVALID_REQUEST + assert excinfo.value.message == INVALID_REQUEST_MESSAGE + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_create_job_deployment(self, service, endpoint): + responses.add(responses.POST, endpoint, json=JOB_PAYLOAD, status=201) + + deployment = JobDeployment( + name=JOB_NAME, + containers=[Container(image='busybox:latest', exposed_port=8080, name=CONTAINER_NAME)], + compute=ComputeResource(name='H100', size=1), + container_registry_settings=ContainerRegistrySettings(is_private=False), + scaling=JobScalingOptions(**SCALING_PAYLOAD), + ) + + created = service.create(deployment) + + assert isinstance(created, JobDeployment) + assert created.name == JOB_NAME + request_body = json.loads(responses.calls[0].request.body.decode('utf-8')) + assert request_body['scaling'] == SCALING_PAYLOAD + assert responses.assert_call_count(endpoint, 1) is True + + @responses.activate + def test_update_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}' + responses.add(responses.PATCH, url, json=JOB_PAYLOAD, status=200) + + deployment = JobDeployment( + name=JOB_NAME, + containers=[Container(image='busybox:latest', exposed_port=8080, name=CONTAINER_NAME)], + compute=ComputeResource(name='H100', size=1), + scaling=JobScalingOptions(**SCALING_PAYLOAD), + ) + + updated = service.update(JOB_NAME, deployment) + + assert isinstance(updated, JobDeployment) + assert updated.name == JOB_NAME + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_delete_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}?timeout=120000' + responses.add(responses.DELETE, url, status=200) + + service.delete(JOB_NAME, timeout=120000) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_status(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/status' + responses.add(responses.GET, url, json={'status': 'running'}, status=200) + + status = service.get_status(JOB_NAME) + + assert status == JobDeploymentStatus.RUNNING + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_get_job_scaling_options(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/scaling' + responses.add(responses.GET, url, json=SCALING_PAYLOAD, status=200) + + scaling = service.get_scaling_options(JOB_NAME) + + assert isinstance(scaling, JobScalingOptions) + assert scaling.max_replica_count == 5 + assert scaling.deadline_seconds == 1800 + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_pause_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/pause' + responses.add(responses.POST, url, status=204) + + service.pause(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_resume_job_deployment(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/resume' + responses.add(responses.POST, url, status=204) + + service.resume(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True + + @responses.activate + def test_purge_job_deployment_queue(self, service, endpoint): + url = f'{endpoint}/{JOB_NAME}/purge-queue' + responses.add(responses.POST, url, status=204) + + service.purge_queue(JOB_NAME) + + assert responses.assert_call_count(url, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index 5d4f2e6..ac95120 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -29,6 +29,7 @@ def test_client(self): assert hasattr(client, 'container_types') assert hasattr(client, 'cluster_types') assert hasattr(client, 'long_term') + assert hasattr(client, 'job_deployments') @responses.activate def test_client_with_default_base_url(self): diff --git a/verda/_verda.py b/verda/_verda.py index 1a0f369..e857c22 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -10,6 +10,7 @@ from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService +from verda.job_deployments import JobDeploymentsService from verda.long_term import LongTermService from verda.locations import LocationsService from verda.ssh_keys import SSHKeysService @@ -83,6 +84,9 @@ def __init__( self.containers: ContainersService = ContainersService(self._http_client, inference_key) """Containers service. Deploy, manage, and monitor container deployments""" + self.job_deployments: JobDeploymentsService = JobDeploymentsService(self._http_client) + """Job deployments service. Deploy and manage serverless jobs""" + self.container_types: ContainerTypesService = ContainerTypesService(self._http_client) """Container types service. Get available serverless container SKUs""" diff --git a/verda/job_deployments/__init__.py b/verda/job_deployments/__init__.py new file mode 100644 index 0000000..b5cf9f4 --- /dev/null +++ b/verda/job_deployments/__init__.py @@ -0,0 +1,15 @@ +from verda.job_deployments._job_deployments import ( + JobDeployment, + JobDeploymentsService, + JobDeploymentStatus, + JobDeploymentSummary, + JobScalingOptions, +) + +__all__ = [ + 'JobDeployment', + 'JobDeploymentStatus', + 'JobDeploymentSummary', + 'JobDeploymentsService', + 'JobScalingOptions', +] diff --git a/verda/job_deployments/_job_deployments.py b/verda/job_deployments/_job_deployments.py new file mode 100644 index 0000000..853fa33 --- /dev/null +++ b/verda/job_deployments/_job_deployments.py @@ -0,0 +1,125 @@ +"""Serverless job deployment service for Verda.""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from dataclasses_json import Undefined, dataclass_json + +from verda.containers import ComputeResource, Container, ContainerRegistrySettings +from verda.http_client import HTTPClient + +JOB_DEPLOYMENTS_ENDPOINT = '/job-deployments' + + +def _strip_none(data: Any) -> Any: + """Recursively remove None values from JSON-serializable data.""" + if isinstance(data, dict): + return {key: _strip_none(value) for key, value in data.items() if value is not None} + if isinstance(data, list): + return [_strip_none(item) for item in data] + return data + + +class JobDeploymentStatus(str, Enum): + """Possible states of a job deployment.""" + + PAUSED = 'paused' + TERMINATING = 'terminating' + RUNNING = 'running' + + +@dataclass_json +@dataclass +class JobScalingOptions: + """Scaling configuration for a job deployment.""" + + max_replica_count: int + queue_message_ttl_seconds: int + deadline_seconds: int + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class JobDeploymentSummary: + """Short job deployment information returned by the list endpoint.""" + + name: str + created_at: str + compute: ComputeResource + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class JobDeployment: + """Configuration and metadata for a serverless job deployment.""" + + name: str + containers: list[Container] + compute: ComputeResource + scaling: JobScalingOptions | None = None + container_registry_settings: ContainerRegistrySettings = field( + default_factory=lambda: ContainerRegistrySettings(is_private=False) + ) + endpoint_base_url: str | None = None + created_at: str | None = None + + +class JobDeploymentsService: + """Service for managing serverless job deployments.""" + + def __init__(self, http_client: HTTPClient) -> None: + self._http_client = http_client + + def get(self) -> list[JobDeploymentSummary]: + """Return all job deployments.""" + response = self._http_client.get(JOB_DEPLOYMENTS_ENDPOINT) + return [JobDeploymentSummary.from_dict(job) for job in response.json()] + + def get_by_name(self, job_name: str) -> JobDeployment: + """Return a job deployment by name.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}') + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def create(self, deployment: JobDeployment) -> JobDeployment: + """Create a new job deployment.""" + response = self._http_client.post( + JOB_DEPLOYMENTS_ENDPOINT, + json=_strip_none(deployment.to_dict()), + ) + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def update(self, job_name: str, deployment: JobDeployment) -> JobDeployment: + """Update an existing job deployment.""" + response = self._http_client.patch( + f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}', + json=_strip_none(deployment.to_dict()), + ) + return JobDeployment.from_dict(response.json(), infer_missing=True) + + def delete(self, job_name: str, timeout: float | None = None) -> None: + """Delete a job deployment.""" + params = {'timeout': timeout} if timeout is not None else None + self._http_client.delete(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}', params=params) + + def get_status(self, job_name: str) -> JobDeploymentStatus: + """Return the current status for a job deployment.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/status') + return JobDeploymentStatus(response.json()['status']) + + def get_scaling_options(self, job_name: str) -> JobScalingOptions: + """Return scaling options for a job deployment.""" + response = self._http_client.get(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/scaling') + return JobScalingOptions.from_dict(response.json()) + + def pause(self, job_name: str) -> None: + """Pause a job deployment.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/pause') + + def resume(self, job_name: str) -> None: + """Resume a job deployment.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/resume') + + def purge_queue(self, job_name: str) -> None: + """Purge the job deployment queue.""" + self._http_client.post(f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}/purge-queue') From 4f08063b8ba7705ae56da88e96346d53777924d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:23:28 +0000 Subject: [PATCH 5/8] feat(instance-types): sync OpenAPI schema fields Co-authored-by: imagene-shahar --- .../instance_types/test_instance_types.py | 61 ++++++++++++++- verda/instance_types/_instance_types.py | 76 ++++++++++++++++--- 2 files changed, 123 insertions(+), 14 deletions(-) diff --git a/tests/unit_tests/instance_types/test_instance_types.py b/tests/unit_tests/instance_types/test_instance_types.py index 459d809..df8d6ee 100644 --- a/tests/unit_tests/instance_types/test_instance_types.py +++ b/tests/unit_tests/instance_types/test_instance_types.py @@ -14,23 +14,39 @@ STORAGE_DESCRIPTION = '1800GB NVME' STORAGE_SIZE = 1800 INSTANCE_TYPE_DESCRIPTION = 'Dedicated Bare metal Server' +BEST_FOR = ['Large model inference', 'Multi-GPU training'] +MODEL = 'V100' +NAME = 'Tesla V100' +P2P = '300 GB/s' PRICE_PER_HOUR = 5.0 SPOT_PRICE_PER_HOUR = 2.5 +MAX_DYNAMIC_PRICE = 7.5 +SERVERLESS_PRICE = 1.25 +SERVERLESS_SPOT_PRICE = 0.75 INSTANCE_TYPE = '8V100.48M' +CURRENCY = 'eur' +MANUFACTURER = 'NVIDIA' +DISPLAY_NAME = 'NVIDIA Tesla V100' +SUPPORTED_OS = ['ubuntu-24.04-cuda-12.8-open-docker'] +PRICE_HISTORY_PAYLOAD = [{'date': '2024-01-01', 'price_per_hour': '2.00'}] + +@responses.activate def test_instance_types(http_client): # arrange - add response mock responses.add( responses.GET, - http_client._base_url + '/instance-types', + http_client._base_url + '/instance-types?currency=eur', json=[ { 'id': TYPE_ID, + 'best_for': BEST_FOR, 'cpu': { 'description': CPU_DESCRIPTION, 'number_of_cores': NUMBER_OF_CORES, }, + 'deploy_warning': 'Use updated drivers', 'gpu': { 'description': GPU_DESCRIPTION, 'number_of_gpus': NUMBER_OF_GPUS, @@ -48,9 +64,19 @@ def test_instance_types(http_client): 'size_in_gigabytes': STORAGE_SIZE, }, 'description': INSTANCE_TYPE_DESCRIPTION, + 'model': MODEL, + 'name': NAME, + 'p2p': P2P, 'price_per_hour': '5.00', 'spot_price': '2.50', + 'max_dynamic_price': '7.50', + 'serverless_price': '1.25', + 'serverless_spot_price': '0.75', 'instance_type': INSTANCE_TYPE, + 'currency': CURRENCY, + 'manufacturer': MANUFACTURER, + 'display_name': DISPLAY_NAME, + 'supported_os': SUPPORTED_OS, } ], status=200, @@ -59,7 +85,7 @@ def test_instance_types(http_client): instance_types_service = InstanceTypesService(http_client) # act - instance_types = instance_types_service.get() + instance_types = instance_types_service.get(currency='eur') instance_type = instance_types[0] # assert @@ -71,6 +97,18 @@ def test_instance_types(http_client): assert instance_type.price_per_hour == PRICE_PER_HOUR assert instance_type.spot_price_per_hour == SPOT_PRICE_PER_HOUR assert instance_type.instance_type == INSTANCE_TYPE + assert instance_type.best_for == BEST_FOR + assert instance_type.model == MODEL + assert instance_type.name == NAME + assert instance_type.p2p == P2P + assert instance_type.currency == CURRENCY + assert instance_type.manufacturer == MANUFACTURER + assert instance_type.display_name == DISPLAY_NAME + assert instance_type.supported_os == SUPPORTED_OS + assert instance_type.deploy_warning == 'Use updated drivers' + assert instance_type.max_dynamic_price == MAX_DYNAMIC_PRICE + assert instance_type.serverless_price == SERVERLESS_PRICE + assert instance_type.serverless_spot_price == SERVERLESS_SPOT_PRICE assert isinstance(instance_type.cpu, dict) assert isinstance(instance_type.gpu, dict) assert isinstance(instance_type.memory, dict) @@ -85,3 +123,22 @@ def test_instance_types(http_client): assert instance_type.memory['size_in_gigabytes'] == MEMORY_SIZE assert instance_type.gpu_memory['size_in_gigabytes'] == GPU_MEMORY_SIZE assert instance_type.storage['size_in_gigabytes'] == STORAGE_SIZE + + +@responses.activate +def test_instance_type_price_history(http_client): + # arrange - add response mock + responses.add( + responses.GET, + http_client._base_url + '/instance-types/price-history', + json=PRICE_HISTORY_PAYLOAD, + status=200, + ) + + instance_types_service = InstanceTypesService(http_client) + + # act + price_history = instance_types_service.get_price_history() + + # assert + assert price_history == PRICE_HISTORY_PAYLOAD diff --git a/verda/instance_types/_instance_types.py b/verda/instance_types/_instance_types.py index 322174d..8f84904 100644 --- a/verda/instance_types/_instance_types.py +++ b/verda/instance_types/_instance_types.py @@ -1,9 +1,12 @@ from dataclasses import dataclass +from typing import Literal from dataclasses_json import dataclass_json INSTANCE_TYPES_ENDPOINT = '/instance-types' +Currency = Literal['usd', 'eur'] + @dataclass_json @dataclass @@ -11,16 +14,16 @@ class InstanceType: """Instance type. Attributes: - id: instance type id. - instance_type: instance type, e.g. '8V100.48M'. - price_per_hour: instance type price per hour. - spot_price_per_hour: instance type spot price per hour. - description: instance type description. - cpu: instance type cpu details. - gpu: instance type gpu details. - memory: instance type memory details. - gpu_memory: instance type gpu memory details. - storage: instance type storage details. + id: Instance type ID. + instance_type: Instance type, e.g. '8V100.48M'. + price_per_hour: Instance type price per hour. + spot_price_per_hour: Instance type spot price per hour. + description: Instance type description. + cpu: Instance type CPU details. + gpu: Instance type GPU details. + memory: Instance type memory details. + gpu_memory: Instance type GPU memory details. + storage: Instance type storage details. """ id: str @@ -33,6 +36,19 @@ class InstanceType: memory: dict gpu_memory: dict storage: dict + best_for: list[str] + model: str + name: str + p2p: str + currency: Currency + manufacturer: str + display_name: str + supported_os: list[str] + deploy_warning: str | None = None + dynamic_price: float | None = None + max_dynamic_price: float | None = None + serverless_price: float | None = None + serverless_spot_price: float | None = None class InstanceTypesService: @@ -41,13 +57,16 @@ class InstanceTypesService: def __init__(self, http_client) -> None: self._http_client = http_client - def get(self) -> list[InstanceType]: + def get(self, currency: Currency = 'usd') -> list[InstanceType]: """Get all instance types. :return: list of instance type objects :rtype: list[InstanceType] """ - instance_types = self._http_client.get(INSTANCE_TYPES_ENDPOINT).json() + instance_types = self._http_client.get( + INSTANCE_TYPES_ENDPOINT, + params={'currency': currency}, + ).json() instance_type_objects = [ InstanceType( id=instance_type['id'], @@ -60,8 +79,41 @@ def get(self) -> list[InstanceType]: memory=instance_type['memory'], gpu_memory=instance_type['gpu_memory'], storage=instance_type['storage'], + best_for=instance_type['best_for'], + model=instance_type['model'], + name=instance_type['name'], + p2p=instance_type['p2p'], + currency=instance_type['currency'], + manufacturer=instance_type['manufacturer'], + display_name=instance_type['display_name'], + supported_os=instance_type['supported_os'], + deploy_warning=instance_type.get('deploy_warning'), + dynamic_price=( + float(instance_type['dynamic_price']) + if instance_type.get('dynamic_price') is not None + else None + ), + max_dynamic_price=( + float(instance_type['max_dynamic_price']) + if instance_type.get('max_dynamic_price') is not None + else None + ), + serverless_price=( + float(instance_type['serverless_price']) + if instance_type.get('serverless_price') is not None + else None + ), + serverless_spot_price=( + float(instance_type['serverless_spot_price']) + if instance_type.get('serverless_spot_price') is not None + else None + ), ) for instance_type in instance_types ] return instance_type_objects + + def get_price_history(self): + """Get the deprecated dynamic price history endpoint as raw JSON.""" + return self._http_client.get(f'{INSTANCE_TYPES_ENDPOINT}/price-history').json() From 702ac99428d9c16d687f7ea2b16243aa632bcc3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 15:24:20 +0000 Subject: [PATCH 6/8] chore(api): normalize shared import ordering Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 4 ++-- datacrunch_compat/datacrunch/datacrunch.py | 4 ++-- verda/_verda.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index b981c58..56d8407 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -16,8 +16,8 @@ instance_types, instances, job_deployments, - long_term, locations, + long_term, ssh_keys, startup_scripts, volume_types, @@ -44,8 +44,8 @@ 'instance_types', 'instances', 'job_deployments', - 'long_term', 'locations', + 'long_term', 'ssh_keys', 'startup_scripts', 'volume_types', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index 038797c..fdfbb1c 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -13,8 +13,8 @@ from verda.instance_types import InstanceTypesService from verda.instances import InstancesService from verda.job_deployments import JobDeploymentsService -from verda.long_term import LongTermService from verda.locations import LocationsService +from verda.long_term import LongTermService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService from verda.volume_types import VolumeTypesService @@ -34,8 +34,8 @@ 'InstanceTypesService', 'InstancesService', 'JobDeploymentsService', - 'LongTermService', 'LocationsService', + 'LongTermService', 'SSHKeysService', 'StartupScriptsService', 'VolumeTypesService', diff --git a/verda/_verda.py b/verda/_verda.py index e857c22..8040e67 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -3,16 +3,16 @@ from verda.balance import BalanceService from verda.cluster_types import ClusterTypesService from verda.clusters import ClustersService -from verda.container_types import ContainerTypesService from verda.constants import Constants +from verda.container_types import ContainerTypesService from verda.containers import ContainersService from verda.http_client import HTTPClient from verda.images import ImagesService from verda.instance_types import InstanceTypesService from verda.instances import InstancesService from verda.job_deployments import JobDeploymentsService -from verda.long_term import LongTermService from verda.locations import LocationsService +from verda.long_term import LongTermService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService from verda.volume_types import VolumeTypesService From d9d6b1c47f0ab22b94a7b31630a463285710dab5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 09:58:23 +0000 Subject: [PATCH 7/8] fix(api): address instance type and helper review comments Remove obsolete dynamic pricing fields from instance types. Move shared payload cleanup into verda.helpers, reuse the shared Currency alias, update client service docstrings, and add helper coverage requested during review. Co-authored-by: imagene-shahar --- .../instance_types/test_instance_types.py | 24 -------------- tests/unit_tests/test_helpers.py | 27 ++++++++++++++++ verda/_verda.py | 4 +-- verda/cluster_types/_cluster_types.py | 5 ++- verda/constants.py | 5 +++ verda/container_types/_container_types.py | 5 ++- verda/helpers.py | 10 ++++++ verda/instance_types/_instance_types.py | 32 ++++++++----------- verda/job_deployments/_job_deployments.py | 15 ++------- 9 files changed, 64 insertions(+), 63 deletions(-) create mode 100644 tests/unit_tests/test_helpers.py diff --git a/tests/unit_tests/instance_types/test_instance_types.py b/tests/unit_tests/instance_types/test_instance_types.py index df8d6ee..2595c54 100644 --- a/tests/unit_tests/instance_types/test_instance_types.py +++ b/tests/unit_tests/instance_types/test_instance_types.py @@ -20,7 +20,6 @@ P2P = '300 GB/s' PRICE_PER_HOUR = 5.0 SPOT_PRICE_PER_HOUR = 2.5 -MAX_DYNAMIC_PRICE = 7.5 SERVERLESS_PRICE = 1.25 SERVERLESS_SPOT_PRICE = 0.75 INSTANCE_TYPE = '8V100.48M' @@ -29,8 +28,6 @@ DISPLAY_NAME = 'NVIDIA Tesla V100' SUPPORTED_OS = ['ubuntu-24.04-cuda-12.8-open-docker'] -PRICE_HISTORY_PAYLOAD = [{'date': '2024-01-01', 'price_per_hour': '2.00'}] - @responses.activate def test_instance_types(http_client): @@ -69,7 +66,6 @@ def test_instance_types(http_client): 'p2p': P2P, 'price_per_hour': '5.00', 'spot_price': '2.50', - 'max_dynamic_price': '7.50', 'serverless_price': '1.25', 'serverless_spot_price': '0.75', 'instance_type': INSTANCE_TYPE, @@ -106,7 +102,6 @@ def test_instance_types(http_client): assert instance_type.display_name == DISPLAY_NAME assert instance_type.supported_os == SUPPORTED_OS assert instance_type.deploy_warning == 'Use updated drivers' - assert instance_type.max_dynamic_price == MAX_DYNAMIC_PRICE assert instance_type.serverless_price == SERVERLESS_PRICE assert instance_type.serverless_spot_price == SERVERLESS_SPOT_PRICE assert isinstance(instance_type.cpu, dict) @@ -123,22 +118,3 @@ def test_instance_types(http_client): assert instance_type.memory['size_in_gigabytes'] == MEMORY_SIZE assert instance_type.gpu_memory['size_in_gigabytes'] == GPU_MEMORY_SIZE assert instance_type.storage['size_in_gigabytes'] == STORAGE_SIZE - - -@responses.activate -def test_instance_type_price_history(http_client): - # arrange - add response mock - responses.add( - responses.GET, - http_client._base_url + '/instance-types/price-history', - json=PRICE_HISTORY_PAYLOAD, - status=200, - ) - - instance_types_service = InstanceTypesService(http_client) - - # act - price_history = instance_types_service.get_price_history() - - # assert - assert price_history == PRICE_HISTORY_PAYLOAD diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py new file mode 100644 index 0000000..afa798e --- /dev/null +++ b/tests/unit_tests/test_helpers.py @@ -0,0 +1,27 @@ +from verda.helpers import strip_none_values + + +def test_strip_none_values_removes_none_recursively(): + data = { + 'name': 'job', + 'optional': None, + 'nested': { + 'keep': 'value', + 'drop': None, + }, + 'items': [ + {'keep': 1, 'drop': None}, + None, + ['value', None], + ], + } + + assert strip_none_values(data) == { + 'name': 'job', + 'nested': {'keep': 'value'}, + 'items': [ + {'keep': 1}, + None, + ['value', None], + ], + } diff --git a/verda/_verda.py b/verda/_verda.py index 8040e67..00479a7 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -88,13 +88,13 @@ def __init__( """Job deployments service. Deploy and manage serverless jobs""" self.container_types: ContainerTypesService = ContainerTypesService(self._http_client) - """Container types service. Get available serverless container SKUs""" + """Container types service. Get available serverless container info""" self.clusters: ClustersService = ClustersService(self._http_client) """Clusters service. Create and manage compute clusters""" self.cluster_types: ClusterTypesService = ClusterTypesService(self._http_client) - """Cluster types service. Get available cluster SKUs""" + """Cluster types service. Get available cluster info""" self.long_term: LongTermService = LongTermService(self._http_client) """Long-term service. Get available long-term pricing periods""" diff --git a/verda/cluster_types/_cluster_types.py b/verda/cluster_types/_cluster_types.py index 806f4fa..6f3a261 100644 --- a/verda/cluster_types/_cluster_types.py +++ b/verda/cluster_types/_cluster_types.py @@ -1,11 +1,10 @@ from dataclasses import dataclass -from typing import Literal from dataclasses_json import dataclass_json -CLUSTER_TYPES_ENDPOINT = '/cluster-types' +from verda.constants import Currency -Currency = Literal['usd', 'eur'] +CLUSTER_TYPES_ENDPOINT = '/cluster-types' @dataclass_json diff --git a/verda/constants.py b/verda/constants.py index 777e7a7..414aa53 100644 --- a/verda/constants.py +++ b/verda/constants.py @@ -1,3 +1,8 @@ +from typing import Literal + +Currency = Literal['usd', 'eur'] + + class Actions: """Instance actions.""" diff --git a/verda/container_types/_container_types.py b/verda/container_types/_container_types.py index 81ec78b..4a1f65d 100644 --- a/verda/container_types/_container_types.py +++ b/verda/container_types/_container_types.py @@ -1,11 +1,10 @@ from dataclasses import dataclass -from typing import Literal from dataclasses_json import dataclass_json -CONTAINER_TYPES_ENDPOINT = '/container-types' +from verda.constants import Currency -Currency = Literal['usd', 'eur'] +CONTAINER_TYPES_ENDPOINT = '/container-types' @dataclass_json diff --git a/verda/helpers.py b/verda/helpers.py index 8bb4ff7..dc92c55 100644 --- a/verda/helpers.py +++ b/verda/helpers.py @@ -1,4 +1,5 @@ import json +from typing import Any def stringify_class_object_properties(class_object: type) -> str: @@ -15,3 +16,12 @@ def stringify_class_object_properties(class_object: type) -> str: if property[:1] != '_' and type(getattr(class_object, property, '')).__name__ != 'method' } return json.dumps(class_properties, indent=2) + + +def strip_none_values(data: Any) -> Any: + """Recursively remove ``None`` values from JSON-serializable data.""" + if isinstance(data, dict): + return {key: strip_none_values(value) for key, value in data.items() if value is not None} + if isinstance(data, list): + return [strip_none_values(item) for item in data] + return data diff --git a/verda/instance_types/_instance_types.py b/verda/instance_types/_instance_types.py index 8f84904..b147f3d 100644 --- a/verda/instance_types/_instance_types.py +++ b/verda/instance_types/_instance_types.py @@ -1,11 +1,10 @@ from dataclasses import dataclass -from typing import Literal from dataclasses_json import dataclass_json -INSTANCE_TYPES_ENDPOINT = '/instance-types' +from verda.constants import Currency -Currency = Literal['usd', 'eur'] +INSTANCE_TYPES_ENDPOINT = '/instance-types' @dataclass_json @@ -24,6 +23,17 @@ class InstanceType: memory: Instance type memory details. gpu_memory: Instance type GPU memory details. storage: Instance type storage details. + best_for: Suggested use cases for the instance type. + model: GPU model. + name: Human-readable instance type name. + p2p: Peer-to-peer interconnect bandwidth details. + currency: Currency used for pricing. + manufacturer: Hardware manufacturer. + display_name: Display name shown to users. + supported_os: Supported operating system images. + deploy_warning: Optional deployment warning returned by the API. + serverless_price: Optional serverless price for the same hardware profile. + serverless_spot_price: Optional serverless spot price for the same hardware profile. """ id: str @@ -45,8 +55,6 @@ class InstanceType: display_name: str supported_os: list[str] deploy_warning: str | None = None - dynamic_price: float | None = None - max_dynamic_price: float | None = None serverless_price: float | None = None serverless_spot_price: float | None = None @@ -88,16 +96,6 @@ def get(self, currency: Currency = 'usd') -> list[InstanceType]: display_name=instance_type['display_name'], supported_os=instance_type['supported_os'], deploy_warning=instance_type.get('deploy_warning'), - dynamic_price=( - float(instance_type['dynamic_price']) - if instance_type.get('dynamic_price') is not None - else None - ), - max_dynamic_price=( - float(instance_type['max_dynamic_price']) - if instance_type.get('max_dynamic_price') is not None - else None - ), serverless_price=( float(instance_type['serverless_price']) if instance_type.get('serverless_price') is not None @@ -113,7 +111,3 @@ def get(self, currency: Currency = 'usd') -> list[InstanceType]: ] return instance_type_objects - - def get_price_history(self): - """Get the deprecated dynamic price history endpoint as raw JSON.""" - return self._http_client.get(f'{INSTANCE_TYPES_ENDPOINT}/price-history').json() diff --git a/verda/job_deployments/_job_deployments.py b/verda/job_deployments/_job_deployments.py index 853fa33..0a75e5a 100644 --- a/verda/job_deployments/_job_deployments.py +++ b/verda/job_deployments/_job_deployments.py @@ -2,25 +2,16 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any from dataclasses_json import Undefined, dataclass_json from verda.containers import ComputeResource, Container, ContainerRegistrySettings +from verda.helpers import strip_none_values from verda.http_client import HTTPClient JOB_DEPLOYMENTS_ENDPOINT = '/job-deployments' -def _strip_none(data: Any) -> Any: - """Recursively remove None values from JSON-serializable data.""" - if isinstance(data, dict): - return {key: _strip_none(value) for key, value in data.items() if value is not None} - if isinstance(data, list): - return [_strip_none(item) for item in data] - return data - - class JobDeploymentStatus(str, Enum): """Possible states of a job deployment.""" @@ -85,7 +76,7 @@ def create(self, deployment: JobDeployment) -> JobDeployment: """Create a new job deployment.""" response = self._http_client.post( JOB_DEPLOYMENTS_ENDPOINT, - json=_strip_none(deployment.to_dict()), + json=strip_none_values(deployment.to_dict()), ) return JobDeployment.from_dict(response.json(), infer_missing=True) @@ -93,7 +84,7 @@ def update(self, job_name: str, deployment: JobDeployment) -> JobDeployment: """Update an existing job deployment.""" response = self._http_client.patch( f'{JOB_DEPLOYMENTS_ENDPOINT}/{job_name}', - json=_strip_none(deployment.to_dict()), + json=strip_none_values(deployment.to_dict()), ) return JobDeployment.from_dict(response.json(), infer_missing=True) From 3d07993a20c2d905dadfcb2d9def49e2b1fa6ecc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 20 Mar 2026 11:54:54 +0000 Subject: [PATCH 8/8] fix(long-term): remove deprecated endpoint support Drop the long-term service, docs, tests, and compatibility exports after review feedback that the endpoint is being deprecated. Co-authored-by: imagene-shahar --- datacrunch_compat/datacrunch/__init__.py | 2 - datacrunch_compat/datacrunch/datacrunch.py | 2 - docs/source/api/services/long_term.rst | 8 --- tests/unit_tests/long_term/test_long_term.py | 58 -------------------- tests/unit_tests/test_client.py | 1 - verda/_verda.py | 4 -- verda/long_term/__init__.py | 3 - verda/long_term/_long_term.py | 40 -------------- 8 files changed, 118 deletions(-) delete mode 100644 docs/source/api/services/long_term.rst delete mode 100644 tests/unit_tests/long_term/test_long_term.py delete mode 100644 verda/long_term/__init__.py delete mode 100644 verda/long_term/_long_term.py diff --git a/datacrunch_compat/datacrunch/__init__.py b/datacrunch_compat/datacrunch/__init__.py index 56d8407..eaa885e 100644 --- a/datacrunch_compat/datacrunch/__init__.py +++ b/datacrunch_compat/datacrunch/__init__.py @@ -17,7 +17,6 @@ instances, job_deployments, locations, - long_term, ssh_keys, startup_scripts, volume_types, @@ -45,7 +44,6 @@ 'instances', 'job_deployments', 'locations', - 'long_term', 'ssh_keys', 'startup_scripts', 'volume_types', diff --git a/datacrunch_compat/datacrunch/datacrunch.py b/datacrunch_compat/datacrunch/datacrunch.py index fdfbb1c..6fea7ce 100644 --- a/datacrunch_compat/datacrunch/datacrunch.py +++ b/datacrunch_compat/datacrunch/datacrunch.py @@ -14,7 +14,6 @@ from verda.instances import InstancesService from verda.job_deployments import JobDeploymentsService from verda.locations import LocationsService -from verda.long_term import LongTermService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService from verda.volume_types import VolumeTypesService @@ -35,7 +34,6 @@ 'InstancesService', 'JobDeploymentsService', 'LocationsService', - 'LongTermService', 'SSHKeysService', 'StartupScriptsService', 'VolumeTypesService', diff --git a/docs/source/api/services/long_term.rst b/docs/source/api/services/long_term.rst deleted file mode 100644 index f15aa82..0000000 --- a/docs/source/api/services/long_term.rst +++ /dev/null @@ -1,8 +0,0 @@ -Long Term -========= - -.. autoclass:: verda.long_term.LongTermService - :members: - -.. autoclass:: verda.long_term.LongTermPeriod - :members: diff --git a/tests/unit_tests/long_term/test_long_term.py b/tests/unit_tests/long_term/test_long_term.py deleted file mode 100644 index 92fa5eb..0000000 --- a/tests/unit_tests/long_term/test_long_term.py +++ /dev/null @@ -1,58 +0,0 @@ -import responses # https://github.com/getsentry/responses - -from verda.long_term import LongTermPeriod, LongTermService - -PERIODS_PAYLOAD = [ - { - 'code': '1-month', - 'name': '1 Month', - 'is_enabled': True, - 'unit_name': 'month', - 'unit_value': 1, - 'discount_percentage': 5, - } -] - - -class TestLongTermService: - @responses.activate - def test_get_periods(self, http_client): - endpoint = http_client._base_url + '/long-term/periods' - responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) - - service = LongTermService(http_client) - - periods = service.get() - - assert isinstance(periods, list) - assert len(periods) == 1 - assert isinstance(periods[0], LongTermPeriod) - assert periods[0].code == '1-month' - assert periods[0].discount_percentage == 5 - assert responses.assert_call_count(endpoint, 1) is True - - @responses.activate - def test_get_instance_periods(self, http_client): - endpoint = http_client._base_url + '/long-term/periods/instances' - responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) - - service = LongTermService(http_client) - - periods = service.get_instances() - - assert len(periods) == 1 - assert periods[0].unit_name == 'month' - assert responses.assert_call_count(endpoint, 1) is True - - @responses.activate - def test_get_cluster_periods(self, http_client): - endpoint = http_client._base_url + '/long-term/periods/clusters' - responses.add(responses.GET, endpoint, json=PERIODS_PAYLOAD, status=200) - - service = LongTermService(http_client) - - periods = service.get_clusters() - - assert len(periods) == 1 - assert periods[0].is_enabled is True - assert responses.assert_call_count(endpoint, 1) is True diff --git a/tests/unit_tests/test_client.py b/tests/unit_tests/test_client.py index ac95120..b1f6029 100644 --- a/tests/unit_tests/test_client.py +++ b/tests/unit_tests/test_client.py @@ -28,7 +28,6 @@ def test_client(self): assert client.constants.base_url == BASE_URL assert hasattr(client, 'container_types') assert hasattr(client, 'cluster_types') - assert hasattr(client, 'long_term') assert hasattr(client, 'job_deployments') @responses.activate diff --git a/verda/_verda.py b/verda/_verda.py index 00479a7..544b775 100644 --- a/verda/_verda.py +++ b/verda/_verda.py @@ -12,7 +12,6 @@ from verda.instances import InstancesService from verda.job_deployments import JobDeploymentsService from verda.locations import LocationsService -from verda.long_term import LongTermService from verda.ssh_keys import SSHKeysService from verda.startup_scripts import StartupScriptsService from verda.volume_types import VolumeTypesService @@ -96,8 +95,5 @@ 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 long-term pricing periods""" - __all__ = ['VerdaClient'] diff --git a/verda/long_term/__init__.py b/verda/long_term/__init__.py deleted file mode 100644 index 7341a3a..0000000 --- a/verda/long_term/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from verda.long_term._long_term import LongTermPeriod, LongTermService - -__all__ = ['LongTermPeriod', 'LongTermService'] diff --git a/verda/long_term/_long_term.py b/verda/long_term/_long_term.py deleted file mode 100644 index ff5e20d..0000000 --- a/verda/long_term/_long_term.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass - -from dataclasses_json import dataclass_json - -LONG_TERM_PERIODS_ENDPOINT = '/long-term/periods' - - -@dataclass_json -@dataclass -class LongTermPeriod: - """Long-term pricing period returned by the public API.""" - - code: str - name: str - is_enabled: bool - unit_name: str - unit_value: float - discount_percentage: float - - -class LongTermService: - """Service for interacting with long-term pricing periods.""" - - def __init__(self, http_client) -> None: - self._http_client = http_client - - def get(self) -> list[LongTermPeriod]: - """Return all long-term periods.""" - periods = self._http_client.get(LONG_TERM_PERIODS_ENDPOINT).json() - return [LongTermPeriod.from_dict(period) for period in periods] - - def get_instances(self) -> list[LongTermPeriod]: - """Return long-term periods available for instances.""" - periods = self._http_client.get(f'{LONG_TERM_PERIODS_ENDPOINT}/instances').json() - return [LongTermPeriod.from_dict(period) for period in periods] - - def get_clusters(self) -> list[LongTermPeriod]: - """Return long-term periods available for clusters.""" - periods = self._http_client.get(f'{LONG_TERM_PERIODS_ENDPOINT}/clusters').json() - return [LongTermPeriod.from_dict(period) for period in periods]