Skip to content

Commit f44e715

Browse files
committed
fix merge confilct
2 parents 19ec59d + 5bd137e commit f44e715

9 files changed

Lines changed: 325 additions & 287 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- `LongTermService` with `get_cluster_periods()` and `get_instance_periods()` methods
1313
- `VolumesService.delete_by_id()` method using `DELETE /v1/volumes/{volume_id}` endpoint
14+
- Support for querying OS images by instance type via `verda.images.get(instance_type=...)`
1415

15-
## [1.22.0] - 2026-03-20
16+
### Changed
17+
18+
- Refactored `Image` model to use `@dataclass` and `@dataclass_json` for consistency with `Instance` and `Volume`
19+
20+
## [1.24.0] - 2026-03-30
21+
22+
### Added
23+
24+
- Added missing fields to the `Volume` class: `pseudo_path`, `mount_command`, `create_directory_command`, `filesystem_to_fstab_command`, `instances`, `contract`, `base_hourly_cost`, `monthly_price`, `currency`, `long_term`
25+
26+
### Changed
27+
28+
- Refactored `Volume` class to use `@dataclass` and `@dataclass_json` for consistency with `Instance` class. `Volume.create_from_dict()` is replaced by `Volume.from_dict()`; unknown API fields are now silently ignored via `Undefined.EXCLUDE`
29+
30+
## [1.23.1] - 2026-03-25
31+
32+
### Fixed
33+
34+
- Fixed volume mount fields (`volume_id`, `secret_name`, `file_names`, `size_in_mb`) being silently dropped during deserialization, causing deployment updates to fail with `volume_mounts.*.volume_id should not be null or undefined`
35+
36+
## [1.23.0] - 2026-03-20
1637

1738
### Added
1839

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "verda"
3-
version = "1.23.0"
3+
version = "1.24.0"
44
description = "Official Python SDK for Verda (formerly DataCrunch) Public API"
55
readme = "README.md"
66
requires-python = ">=3.10"

tests/unit_tests/images/test_images.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
1+
import json
2+
13
import responses # https://github.com/getsentry/responses
4+
from responses import matchers
25

36
from verda.images import Image, ImagesService
47

8+
IMAGE_RESPONSE = {
9+
'id': '0888da25-bb0d-41cc-a191-dccae45d96fd',
10+
'name': 'Ubuntu 20.04 + CUDA 11.0',
11+
'details': ['Ubuntu 20.04', 'CUDA 11.0'],
12+
'image_type': 'ubuntu-20.04-cuda-11.0',
13+
}
14+
515

616
def test_images(http_client):
7-
# arrange - add response mock
17+
# arrange
818
responses.add(
919
responses.GET,
1020
http_client._base_url + '/images',
11-
json=[
12-
{
13-
'id': '0888da25-bb0d-41cc-a191-dccae45d96fd',
14-
'name': 'Ubuntu 20.04 + CUDA 11.0',
15-
'details': ['Ubuntu 20.04', 'CUDA 11.0'],
16-
'image_type': 'ubuntu-20.04-cuda-11.0',
17-
}
18-
],
21+
json=[IMAGE_RESPONSE],
1922
status=200,
2023
)
2124

@@ -34,4 +37,27 @@ def test_images(http_client):
3437
assert isinstance(images[0].details, list)
3538
assert images[0].details[0] == 'Ubuntu 20.04'
3639
assert images[0].details[1] == 'CUDA 11.0'
37-
assert isinstance(images[0].__str__(), str)
40+
assert json.loads(str(images[0])) == IMAGE_RESPONSE
41+
42+
43+
def test_images_filter_by_instance_type(http_client):
44+
# arrange
45+
responses.add(
46+
responses.GET,
47+
http_client._base_url + '/images',
48+
match=[matchers.query_param_matcher({'instance_type': '1A100.22V'})],
49+
json=[IMAGE_RESPONSE],
50+
status=200,
51+
)
52+
53+
image_service = ImagesService(http_client)
54+
55+
# act
56+
images = image_service.get(instance_type='1A100.22V')
57+
58+
# assert
59+
assert isinstance(images, list)
60+
assert len(images) == 1
61+
assert isinstance(images[0], Image)
62+
assert images[0].id == '0888da25-bb0d-41cc-a191-dccae45d96fd'
63+
assert images[0].image_type == 'ubuntu-20.04-cuda-11.0'

tests/unit_tests/job_deployments/test_job_deployments.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import json
2+
from dataclasses import replace
23

34
import pytest
45
import responses # https://github.com/getsentry/responses
56

67
from verda.containers import ComputeResource, Container, ContainerRegistrySettings
8+
from verda.containers._containers import (
9+
GeneralStorageMount,
10+
MemoryMount,
11+
SecretMount,
12+
SharedFileSystemMount,
13+
)
714
from verda.exceptions import APIException
815
from verda.job_deployments import (
916
JobDeployment,
@@ -207,3 +214,71 @@ def test_purge_job_deployment_queue(self, service, endpoint):
207214
service.purge_queue(JOB_NAME)
208215

209216
assert responses.assert_call_count(url, 1) is True
217+
218+
@responses.activate
219+
def test_update_preserves_volume_mounts_round_trip(self, service, endpoint):
220+
"""Regression test: volume mount subclass fields (volume_id, secret_name, etc.)
221+
must survive a get → update round trip without being dropped during deserialization."""
222+
volume_id = '550e8400-e29b-41d4-a716-446655440000'
223+
api_payload = {
224+
'name': JOB_NAME,
225+
'containers': [
226+
{
227+
'name': CONTAINER_NAME,
228+
'image': 'busybox:latest',
229+
'exposed_port': 8080,
230+
'env': [],
231+
'volume_mounts': [
232+
{'type': 'scratch', 'mount_path': '/data'},
233+
{'type': 'shared', 'mount_path': '/sfs', 'volume_id': volume_id},
234+
{
235+
'type': 'secret',
236+
'mount_path': '/secrets',
237+
'secret_name': 'my-secret',
238+
'file_names': ['key.pem'],
239+
},
240+
{'type': 'memory', 'mount_path': '/dev/shm', 'size_in_mb': 512},
241+
],
242+
}
243+
],
244+
'endpoint_base_url': 'https://test-job.datacrunch.io',
245+
'created_at': '2024-01-01T00:00:00Z',
246+
'compute': {'name': 'H100', 'size': 1},
247+
'container_registry_settings': {'is_private': False, 'credentials': None},
248+
}
249+
250+
get_url = f'{endpoint}/{JOB_NAME}'
251+
responses.add(responses.GET, get_url, json=api_payload, status=200)
252+
responses.add(responses.PATCH, get_url, json=api_payload, status=200)
253+
254+
# Simulate the user's flow: get → modify image → update
255+
deployment = service.get_by_name(JOB_NAME)
256+
257+
# Verify deserialization produced the correct subclasses
258+
vms = deployment.containers[0].volume_mounts
259+
assert isinstance(vms[0], GeneralStorageMount)
260+
assert isinstance(vms[1], SharedFileSystemMount)
261+
assert vms[1].volume_id == volume_id
262+
assert isinstance(vms[2], SecretMount)
263+
assert vms[2].secret_name == 'my-secret'
264+
assert vms[2].file_names == ['key.pem']
265+
assert isinstance(vms[3], MemoryMount)
266+
assert vms[3].size_in_mb == 512
267+
268+
# Update only the image (exactly what the reported user script does)
269+
containers = list(deployment.containers)
270+
containers[0] = replace(containers[0], image='busybox:v2')
271+
updated_deployment = replace(deployment, containers=containers)
272+
273+
service.update(JOB_NAME, updated_deployment)
274+
275+
# Verify the PATCH request body still contains volume_id
276+
request_body = json.loads(responses.calls[1].request.body.decode('utf-8'))
277+
sent_vms = request_body['containers'][0]['volume_mounts']
278+
assert sent_vms[0]['type'] == 'scratch'
279+
assert sent_vms[1]['type'] == 'shared'
280+
assert sent_vms[1]['volume_id'] == volume_id
281+
assert sent_vms[2]['type'] == 'secret'
282+
assert sent_vms[2]['secret_name'] == 'my-secret'
283+
assert sent_vms[3]['type'] == 'memory'
284+
assert sent_vms[3]['size_in_mb'] == 512

tests/unit_tests/volumes/test_volumes.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,25 @@
4949
'is_os_volume': True,
5050
'created_at': NVME_VOL_CREATED_AT,
5151
'target': TARGET_VDA,
52-
'ssh_key_ids': SSH_KEY_ID,
52+
'ssh_key_ids': [SSH_KEY_ID],
53+
'pseudo_path': 'volume-nxC2tf9F',
54+
'mount_command': 'mount -t nfs -o nconnect=16 nfs.fin-01.datacrunch.io:volume-nxC2tf9F /mnt/volume',
55+
'create_directory_command': 'mkdir -p /mnt/volume',
56+
'filesystem_to_fstab_command': "grep -qxF 'nfs.fin-01.datacrunch.io:volume-nxC2tf9F /mnt/volume nfs defaults 0 0' /etc/fstab || echo 'nfs.fin-01.datacrunch.io:volume-nxC2tf9F /mnt/volume nfs defaults 0 0' | sudo tee -a /etc/fstab",
57+
'instances': [
58+
{
59+
'id': INSTANCE_ID,
60+
'ip': '123.123.123.123',
61+
'instance_type': '4A100.88V',
62+
'status': 'running',
63+
'hostname': 'hazy-star-swims-fin-01',
64+
}
65+
],
66+
'contract': 'PAY_AS_YOU_GO',
67+
'base_hourly_cost': 0.0273972602739726,
68+
'monthly_price': 20,
69+
'currency': 'eur',
70+
'long_term': None,
5371
}
5472

5573
HDD_VOLUME = {
@@ -64,6 +82,16 @@
6482
'created_at': HDD_VOL_CREATED_AT,
6583
'target': None,
6684
'ssh_key_ids': [],
85+
'pseudo_path': 'volume-iHdL4ysR',
86+
'mount_command': 'mount -t nfs -o nconnect=16 nfs.fin-01.datacrunch.io:volume-iHdL4ysR /mnt/volume',
87+
'create_directory_command': 'mkdir -p /mnt/volume',
88+
'filesystem_to_fstab_command': "grep -qxF 'nfs.fin-01.datacrunch.io:volume-iHdL4ysR /mnt/volume nfs defaults 0 0' /etc/fstab || echo 'nfs.fin-01.datacrunch.io:volume-iHdL4ysR /mnt/volume nfs defaults 0 0' | sudo tee -a /etc/fstab",
89+
'instances': [],
90+
'contract': 'PAY_AS_YOU_GO',
91+
'base_hourly_cost': 0.01,
92+
'monthly_price': 10,
93+
'currency': 'eur',
94+
'long_term': None,
6795
}
6896

6997
PAYLOAD = [NVME_VOLUME, HDD_VOLUME]
@@ -80,13 +108,13 @@ def endpoint(self, http_client):
80108

81109
def test_initialize_a_volume(self):
82110
volume = Volume(
83-
RANDOM_VOL_ID,
84-
VolumeStatus.DETACHED,
85-
HDD_VOL_NAME,
86-
HDD_VOL_SIZE,
87-
HDD,
88-
False,
89-
HDD_VOL_CREATED_AT,
111+
id=RANDOM_VOL_ID,
112+
status=VolumeStatus.DETACHED,
113+
name=HDD_VOL_NAME,
114+
size=HDD_VOL_SIZE,
115+
type=HDD,
116+
is_os_volume=False,
117+
created_at=HDD_VOL_CREATED_AT,
90118
)
91119

92120
assert volume.id == RANDOM_VOL_ID
@@ -101,6 +129,34 @@ def test_initialize_a_volume(self):
101129
assert volume.target is None
102130
assert volume.ssh_key_ids == []
103131

132+
def test_from_dict_without_optional_fields(self):
133+
"""Test that from_dict handles API responses missing optional fields."""
134+
minimal_dict = {
135+
'id': RANDOM_VOL_ID,
136+
'status': VolumeStatus.DETACHED,
137+
'name': HDD_VOL_NAME,
138+
'size': HDD_VOL_SIZE,
139+
'type': HDD,
140+
'is_os_volume': False,
141+
'created_at': HDD_VOL_CREATED_AT,
142+
'target': None,
143+
'location': Locations.FIN_01,
144+
'instance_id': None,
145+
'ssh_key_ids': [],
146+
}
147+
volume = Volume.from_dict(minimal_dict)
148+
assert volume.id == RANDOM_VOL_ID
149+
assert volume.pseudo_path is None
150+
assert volume.mount_command is None
151+
assert volume.create_directory_command is None
152+
assert volume.filesystem_to_fstab_command is None
153+
assert volume.instances is None
154+
assert volume.contract is None
155+
assert volume.base_hourly_cost is None
156+
assert volume.monthly_price is None
157+
assert volume.currency is None
158+
assert volume.long_term is None
159+
104160
def test_get_volumes(self, volumes_service, endpoint):
105161
# arrange - add response mock
106162
responses.add(responses.GET, endpoint, json=PAYLOAD, status=200)
@@ -125,7 +181,17 @@ def test_get_volumes(self, volumes_service, endpoint):
125181
assert volume_nvme.is_os_volume
126182
assert volume_nvme.created_at == NVME_VOL_CREATED_AT
127183
assert volume_nvme.target == TARGET_VDA
128-
assert volume_nvme.ssh_key_ids == SSH_KEY_ID
184+
assert volume_nvme.ssh_key_ids == [SSH_KEY_ID]
185+
assert volume_nvme.pseudo_path == NVME_VOLUME['pseudo_path']
186+
assert volume_nvme.mount_command == NVME_VOLUME['mount_command']
187+
assert volume_nvme.create_directory_command == NVME_VOLUME['create_directory_command']
188+
assert volume_nvme.filesystem_to_fstab_command == NVME_VOLUME['filesystem_to_fstab_command']
189+
assert volume_nvme.instances == NVME_VOLUME['instances']
190+
assert volume_nvme.contract == 'PAY_AS_YOU_GO'
191+
assert volume_nvme.base_hourly_cost == NVME_VOLUME['base_hourly_cost']
192+
assert volume_nvme.monthly_price == 20
193+
assert volume_nvme.currency == 'eur'
194+
assert volume_nvme.long_term is None
129195

130196
assert volume_hdd.id == HDD_VOL_ID
131197
assert volume_hdd.status == HDD_VOL_STATUS

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

verda/containers/_containers.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from enum import Enum
1111
from typing import Any
1212

13-
from dataclasses_json import Undefined, dataclass_json # type: ignore
13+
from dataclasses_json import Undefined, config, dataclass_json # type: ignore
1414

1515
from verda.http_client import HTTPClient
1616
from verda.inference_client import InferenceClient, InferenceResponse
@@ -203,6 +203,29 @@ def __init__(self, mount_path: str, volume_id: str):
203203
self.volume_id = volume_id
204204

205205

206+
def _decode_volume_mount(data: dict) -> VolumeMount:
207+
"""Decode a volume mount dict into the correct VolumeMount subclass based on type."""
208+
mount_type = data.get('type')
209+
if mount_type == VolumeMountType.SHARED or mount_type == 'shared':
210+
return SharedFileSystemMount(mount_path=data['mount_path'], volume_id=data['volume_id'])
211+
if mount_type == VolumeMountType.SECRET or mount_type == 'secret':
212+
return SecretMount(
213+
mount_path=data['mount_path'],
214+
secret_name=data['secret_name'],
215+
file_names=data.get('file_names'),
216+
)
217+
if mount_type == VolumeMountType.MEMORY or mount_type == 'memory':
218+
return MemoryMount(size_in_mb=data['size_in_mb'])
219+
return GeneralStorageMount(mount_path=data['mount_path'])
220+
221+
222+
def _decode_volume_mounts(data: list[dict] | None) -> list[VolumeMount] | None:
223+
"""Decode a list of volume mount dicts into the correct VolumeMount subclasses."""
224+
if not data:
225+
return None
226+
return [_decode_volume_mount(v) for v in data]
227+
228+
206229
@dataclass_json
207230
@dataclass
208231
class Container:
@@ -224,7 +247,9 @@ class Container:
224247
healthcheck: HealthcheckSettings | None = None
225248
entrypoint_overrides: EntrypointOverridesSettings | None = None
226249
env: list[EnvVar] | None = None
227-
volume_mounts: list[VolumeMount] | None = None
250+
volume_mounts: list[VolumeMount] | None = field(
251+
default=None, metadata=config(decoder=_decode_volume_mounts)
252+
)
228253

229254

230255
@dataclass_json

0 commit comments

Comments
 (0)