From 5bd08c712a946207fd3143c722a89a69ade3df66 Mon Sep 17 00:00:00 2001 From: Devin Date: Fri, 10 Apr 2026 10:10:36 -0700 Subject: [PATCH 1/4] add volume mounts --- README.md | 29 ++++ hyperbrowser/client/async_client.py | 2 + .../client/managers/async_manager/volume.py | 26 +++ .../client/managers/sync_manager/volume.py | 26 +++ hyperbrowser/client/sync.py | 2 + hyperbrowser/models/__init__.py | 9 + hyperbrowser/models/sandbox.py | 11 ++ hyperbrowser/models/volume.py | 37 ++++ tests/test_create_sandbox_params.py | 25 +++ tests/test_sandbox_wire_contract.py | 43 +++++ tests/test_volume_wire_contract.py | 162 ++++++++++++++++++ 11 files changed, 372 insertions(+) create mode 100644 hyperbrowser/client/managers/async_manager/volume.py create mode 100644 hyperbrowser/client/managers/sync_manager/volume.py create mode 100644 hyperbrowser/models/volume.py create mode 100644 tests/test_volume_wire_contract.py diff --git a/README.md b/README.md index d099f933..9f62088a 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,35 @@ client.close() `cpu`, `memory_mib`, and `disk_mib` are only supported for image launches. +### Manage volumes and mount them in a sandbox + +```python +from hyperbrowser import Hyperbrowser +from hyperbrowser.models import CreateSandboxParams, CreateVolumeParams, SandboxVolumeMount + +client = Hyperbrowser(api_key="test-key") + +volume = client.volumes.create(CreateVolumeParams(name="project-cache")) +all_volumes = client.volumes.list() +same_volume = client.volumes.get(volume.id) + +sandbox = client.sandboxes.create( + CreateSandboxParams( + image_name="node", + mounts={ + "/workspace/cache": SandboxVolumeMount( + id=same_volume.id, + type="rw", + shared=True, + ) + }, + ) +) + +sandbox.stop() +client.close() +``` + ### List sandboxes with filters ```python diff --git a/hyperbrowser/client/async_client.py b/hyperbrowser/client/async_client.py index a6c9b3d2..ce4153a3 100644 --- a/hyperbrowser/client/async_client.py +++ b/hyperbrowser/client/async_client.py @@ -14,6 +14,7 @@ from .managers.async_manager.session import SessionManager from .managers.async_manager.team import TeamManager from .managers.async_manager.computer_action import ComputerActionManager +from .managers.async_manager.volume import VolumeManager class AsyncHyperbrowser(HyperbrowserBase): @@ -47,6 +48,7 @@ def __init__( self.team = TeamManager(self) self.computer_action = ComputerActionManager(self) self.sandboxes = SandboxManager(self) + self.volumes = VolumeManager(self) async def close(self) -> None: await self.transport.close() diff --git a/hyperbrowser/client/managers/async_manager/volume.py b/hyperbrowser/client/managers/async_manager/volume.py new file mode 100644 index 00000000..4c9d11cf --- /dev/null +++ b/hyperbrowser/client/managers/async_manager/volume.py @@ -0,0 +1,26 @@ +from hyperbrowser.models.volume import CreateVolumeParams, Volume, VolumeListResponse + + +class VolumeManager: + def __init__(self, client): + self._client = client + + async def create(self, params: CreateVolumeParams) -> Volume: + if not isinstance(params, CreateVolumeParams): + raise TypeError("params must be a CreateVolumeParams instance") + + response = await self._client.transport.post( + self._client._build_url("/volume"), + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return Volume(**response.data) + + async def list(self) -> VolumeListResponse: + response = await self._client.transport.get(self._client._build_url("/volume")) + return VolumeListResponse(**response.data) + + async def get(self, volume_id: str) -> Volume: + response = await self._client.transport.get( + self._client._build_url(f"/volume/{volume_id}") + ) + return Volume(**response.data) diff --git a/hyperbrowser/client/managers/sync_manager/volume.py b/hyperbrowser/client/managers/sync_manager/volume.py new file mode 100644 index 00000000..e0496376 --- /dev/null +++ b/hyperbrowser/client/managers/sync_manager/volume.py @@ -0,0 +1,26 @@ +from hyperbrowser.models.volume import CreateVolumeParams, Volume, VolumeListResponse + + +class VolumeManager: + def __init__(self, client): + self._client = client + + def create(self, params: CreateVolumeParams) -> Volume: + if not isinstance(params, CreateVolumeParams): + raise TypeError("params must be a CreateVolumeParams instance") + + response = self._client.transport.post( + self._client._build_url("/volume"), + data=params.model_dump(exclude_none=True, by_alias=True), + ) + return Volume(**response.data) + + def list(self) -> VolumeListResponse: + response = self._client.transport.get(self._client._build_url("/volume")) + return VolumeListResponse(**response.data) + + def get(self, volume_id: str) -> Volume: + response = self._client.transport.get( + self._client._build_url(f"/volume/{volume_id}") + ) + return Volume(**response.data) diff --git a/hyperbrowser/client/sync.py b/hyperbrowser/client/sync.py index f08e42d3..7d6821e7 100644 --- a/hyperbrowser/client/sync.py +++ b/hyperbrowser/client/sync.py @@ -14,6 +14,7 @@ from .managers.sync_manager.session import SessionManager from .managers.sync_manager.team import TeamManager from .managers.sync_manager.computer_action import ComputerActionManager +from .managers.sync_manager.volume import VolumeManager class Hyperbrowser(HyperbrowserBase): @@ -47,6 +48,7 @@ def __init__( self.team = TeamManager(self) self.computer_action = ComputerActionManager(self) self.sandboxes = SandboxManager(self) + self.volumes = VolumeManager(self) def close(self) -> None: self.transport.close() diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 694c8fd4..6e531db2 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -173,6 +173,7 @@ ProfileListResponse, ProfileResponse, ) +from .volume import CreateVolumeParams, Volume, VolumeListResponse from .scrape import ( BatchScrapeJobResponse, BatchScrapeJobStatusResponse, @@ -245,6 +246,8 @@ Sandbox, SandboxDetail, SandboxRuntimeSession, + SandboxVolumeMountType, + SandboxVolumeMount, CreateSandboxParams, StartSandboxFromSnapshotParams, SandboxListParams, @@ -450,6 +453,10 @@ "ProfileListParams", "ProfileListResponse", "ProfileResponse", + # volume + "CreateVolumeParams", + "Volume", + "VolumeListResponse", # scrape "BatchScrapeJobResponse", "BatchScrapeJobStatusResponse", @@ -499,6 +506,8 @@ "Sandbox", "SandboxDetail", "SandboxRuntimeSession", + "SandboxVolumeMountType", + "SandboxVolumeMount", "CreateSandboxParams", "StartSandboxFromSnapshotParams", "SandboxListParams", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index a5e3a51f..ea7f869b 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -29,6 +29,7 @@ SandboxFileReadFormat = Literal["text", "bytes", "blob", "stream"] SandboxFileWatchRoute = Literal["ws", "stream"] SandboxFileSystemEventType = Literal["chmod", "create", "remove", "rename", "write"] +SandboxVolumeMountType = Literal["rw", "ro"] def _parse_optional_datetime(value): @@ -95,6 +96,12 @@ class SandboxUnexposeResult(SandboxBaseModel): exposed: bool +class SandboxVolumeMount(SandboxBaseModel): + id: str + type: Optional[SandboxVolumeMountType] = None + shared: Optional[bool] = None + + class Sandbox(SandboxBaseModel): id: str team_id: str = Field(alias="teamId") @@ -180,6 +187,10 @@ class CreateSandboxParams(SandboxBaseModel): default=None, serialization_alias="exposedPorts", ) + mounts: Optional[Dict[str, SandboxVolumeMount]] = Field( + default=None, + serialization_alias="mounts", + ) timeout_minutes: Optional[int] = Field( default=None, serialization_alias="timeoutMinutes" ) diff --git a/hyperbrowser/models/volume.py b/hyperbrowser/models/volume.py new file mode 100644 index 00000000..56b6a913 --- /dev/null +++ b/hyperbrowser/models/volume.py @@ -0,0 +1,37 @@ +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _parse_optional_int(value): + if value is None or isinstance(value, int): + return value + if isinstance(value, str) and value.strip() == "": + return None + if isinstance(value, str): + return int(value) + return value + + +class VolumeBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + +class CreateVolumeParams(VolumeBaseModel): + name: str + + +class Volume(VolumeBaseModel): + id: str + name: str + size: Optional[int] = None + transfer_amount: Optional[int] = Field(default=None, alias="transferAmount") + + @field_validator("size", "transfer_amount", mode="before") + @classmethod + def parse_optional_int_fields(cls, value): + return _parse_optional_int(value) + + +class VolumeListResponse(VolumeBaseModel): + volumes: List[Volume] diff --git a/tests/test_create_sandbox_params.py b/tests/test_create_sandbox_params.py index 0416f4bd..6ef57f62 100644 --- a/tests/test_create_sandbox_params.py +++ b/tests/test_create_sandbox_params.py @@ -9,6 +9,7 @@ SandboxProcessListParams, SandboxProcessWaitParams, SandboxSnapshotListParams, + SandboxVolumeMount, ) @@ -36,6 +37,30 @@ def test_create_sandbox_params_serializes_exposed_ports(): } +def test_create_sandbox_params_serializes_mounts(): + params = CreateSandboxParams( + image_name="node", + mounts={ + "/workspace/cache": SandboxVolumeMount( + id="2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + type="rw", + shared=True, + ) + }, + ) + + assert params.model_dump(by_alias=True, exclude_none=True) == { + "imageName": "node", + "mounts": { + "/workspace/cache": { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "type": "rw", + "shared": True, + } + }, + } + + def test_create_sandbox_params_accepts_snapshot_source(): params = CreateSandboxParams(snapshot_name="snap", snapshot_id="snap-id") diff --git a/tests/test_sandbox_wire_contract.py b/tests/test_sandbox_wire_contract.py index 5ba42052..966f09e9 100644 --- a/tests/test_sandbox_wire_contract.py +++ b/tests/test_sandbox_wire_contract.py @@ -42,6 +42,7 @@ SandboxTerminalKillParams, SandboxTerminalWaitParams, SandboxUnexposeResult, + SandboxVolumeMount, ) @@ -498,6 +499,13 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], + mounts={ + "/workspace/cache": SandboxVolumeMount( + id="2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + type="rw", + shared=True, + ) + }, timeout_minutes=15, ).model_dump(by_alias=True, exclude_none=True) == { "imageName": "node", @@ -507,6 +515,13 @@ def test_sandbox_request_models_serialize_expected_wire_keys(): "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], + "mounts": { + "/workspace/cache": { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "type": "rw", + "shared": True, + } + }, "timeoutMinutes": 15, } @@ -631,6 +646,13 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], + mounts={ + "/workspace/cache": SandboxVolumeMount( + id="2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + type="rw", + shared=True, + ) + }, timeout_minutes=15, ) ) @@ -681,6 +703,13 @@ def test_sync_sandbox_control_manager_uses_expected_wire_keys(): "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], + "mounts": { + "/workspace/cache": { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "type": "rw", + "shared": True, + } + }, "timeoutMinutes": 15, } assert snapshot_call["json"] == { @@ -915,6 +944,13 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): disk_mib=8192, enable_recording=True, exposed_ports=[SandboxExposeParams(port=3000, auth=True)], + mounts={ + "/workspace/cache": SandboxVolumeMount( + id="2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + type="rw", + shared=True, + ) + }, timeout_minutes=15, ) ) @@ -965,6 +1001,13 @@ async def test_async_sandbox_control_manager_uses_expected_wire_keys(): "diskSizeMiB": 8192, "enableRecording": True, "exposedPorts": [{"port": 3000, "auth": True}], + "mounts": { + "/workspace/cache": { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "type": "rw", + "shared": True, + } + }, "timeoutMinutes": 15, } assert snapshot_call["json"] == { diff --git a/tests/test_volume_wire_contract.py b/tests/test_volume_wire_contract.py new file mode 100644 index 00000000..efc17b19 --- /dev/null +++ b/tests/test_volume_wire_contract.py @@ -0,0 +1,162 @@ +import pytest + +from hyperbrowser.client.managers.async_manager.volume import VolumeManager as AsyncVolumeManager +from hyperbrowser.client.managers.sync_manager.volume import VolumeManager +from hyperbrowser.models import CreateVolumeParams, Volume + + +VOLUME_PAYLOAD = { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "name": "project-cache", + "size": 0, + "transferAmount": 0, +} + +VOLUME_DETAIL_PAYLOAD = { + "id": "2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + "name": "project-cache", +} + +VOLUME_LIST_PAYLOAD = { + "volumes": [VOLUME_PAYLOAD], +} + + +class StubResponse: + def __init__(self, data): + self.data = data + + +class RecordingSyncTransport: + def __init__(self): + self.calls = [] + + def post(self, url, data=None, files=None): + self.calls.append({"method": "POST", "url": url, "data": data, "files": files}) + return StubResponse(VOLUME_PAYLOAD) + + def get(self, url, params=None, follow_redirects=False): + self.calls.append( + { + "method": "GET", + "url": url, + "params": params, + "follow_redirects": follow_redirects, + } + ) + if url.endswith("/volume"): + return StubResponse(VOLUME_LIST_PAYLOAD) + if url.endswith(f"/volume/{VOLUME_DETAIL_PAYLOAD['id']}"): + return StubResponse(VOLUME_DETAIL_PAYLOAD) + return StubResponse({}) + + +class RecordingAsyncTransport: + def __init__(self): + self.calls = [] + + async def post(self, url, data=None, files=None): + self.calls.append({"method": "POST", "url": url, "data": data, "files": files}) + return StubResponse(VOLUME_PAYLOAD) + + async def get(self, url, params=None, follow_redirects=False): + self.calls.append( + { + "method": "GET", + "url": url, + "params": params, + "follow_redirects": follow_redirects, + } + ) + if url.endswith("/volume"): + return StubResponse(VOLUME_LIST_PAYLOAD) + if url.endswith(f"/volume/{VOLUME_DETAIL_PAYLOAD['id']}"): + return StubResponse(VOLUME_DETAIL_PAYLOAD) + return StubResponse({}) + + +class FakeSyncClient: + def __init__(self): + self.transport = RecordingSyncTransport() + + def _build_url(self, path: str) -> str: + return f"https://api.example.com{path}" + + +class FakeAsyncClient: + def __init__(self): + self.transport = RecordingAsyncTransport() + + def _build_url(self, path: str) -> str: + return f"https://api.example.com{path}" + + +def test_volume_models_serialize_and_parse_expected_wire_keys(): + assert CreateVolumeParams(name="project-cache").model_dump( + by_alias=True, exclude_none=True + ) == { + "name": "project-cache", + } + + parsed = Volume( + id="2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d", + name="project-cache", + size="42", + transferAmount="7", + ) + assert parsed.size == 42 + assert parsed.transfer_amount == 7 + + +def test_sync_volume_manager_uses_expected_wire_keys(): + client = FakeSyncClient() + manager = VolumeManager(client) + + created = manager.create(CreateVolumeParams(name="project-cache")) + listed = manager.list() + fetched = manager.get("2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + + create_call = client.transport.calls[0] + list_call = client.transport.calls[1] + get_call = client.transport.calls[2] + + assert create_call["method"] == "POST" + assert create_call["url"].endswith("/volume") + assert create_call["data"] == {"name": "project-cache"} + + assert list_call["method"] == "GET" + assert list_call["url"].endswith("/volume") + assert listed.volumes[0].transfer_amount == 0 + + assert get_call["method"] == "GET" + assert get_call["url"].endswith("/volume/2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + assert created.size == 0 + assert fetched.name == "project-cache" + assert fetched.transfer_amount is None + + +@pytest.mark.anyio +async def test_async_volume_manager_uses_expected_wire_keys(): + client = FakeAsyncClient() + manager = AsyncVolumeManager(client) + + created = await manager.create(CreateVolumeParams(name="project-cache")) + listed = await manager.list() + fetched = await manager.get("2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + + create_call = client.transport.calls[0] + list_call = client.transport.calls[1] + get_call = client.transport.calls[2] + + assert create_call["method"] == "POST" + assert create_call["url"].endswith("/volume") + assert create_call["data"] == {"name": "project-cache"} + + assert list_call["method"] == "GET" + assert list_call["url"].endswith("/volume") + assert listed.volumes[0].size == 0 + + assert get_call["method"] == "GET" + assert get_call["url"].endswith("/volume/2d6f01cf-c5d7-4c61-ae9e-0264f1c8063d") + assert created.transfer_amount == 0 + assert fetched.name == "project-cache" From 9a2ade3f9756492d12104d40ac776a26e78fe8cc Mon Sep 17 00:00:00 2001 From: Devin Date: Fri, 10 Apr 2026 10:17:32 -0700 Subject: [PATCH 2/4] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5298aed..19d85c87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hyperbrowser" -version = "0.90.1" +version = "0.90.2" description = "Python SDK for hyperbrowser" authors = ["Nikhil Shahi "] license = "MIT" From 1f88c24edc85571d07fecafee1d630da46e4d7c2 Mon Sep 17 00:00:00 2001 From: Nikhil Shahi Date: Fri, 10 Apr 2026 14:18:25 -0700 Subject: [PATCH 3/4] format --- .../sync_manager/sandboxes/sandbox_files.py | 4 +- tests/sandbox/e2e/test_async_files.py | 116 ++++++------------ tests/sandbox/e2e/test_files.py | 69 ++++------- tests/test_volume_wire_contract.py | 4 +- 4 files changed, 62 insertions(+), 131 deletions(-) diff --git a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py index e9ad175a..ebaaca57 100644 --- a/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py +++ b/hyperbrowser/client/managers/sync_manager/sandboxes/sandbox_files.py @@ -292,8 +292,8 @@ def list( "/sandbox/files", params=self._with_run_as_params( { - "path": path, - "depth": depth, + "path": path, + "depth": depth, } ), ) diff --git a/tests/sandbox/e2e/test_async_files.py b/tests/sandbox/e2e/test_async_files.py index 4e45e658..5b147bb5 100644 --- a/tests/sandbox/e2e/test_async_files.py +++ b/tests/sandbox/e2e/test_async_files.py @@ -90,9 +90,7 @@ async def test_async_sandbox_files_e2e(): await files.make_dir(f"{list_dir}/nested/inner", parents=True) await files.write_text(f"{list_dir}/root.txt", "root") await files.write_text(f"{list_dir}/nested/child.txt", "child") - await files.write_text( - f"{list_dir}/nested/inner/grandchild.txt", "grandchild" - ) + await files.write_text(f"{list_dir}/nested/inner/grandchild.txt", "grandchild") depth_one = await files.list(list_dir, depth=1) assert [entry.name for entry in depth_one] == ["nested", "root.txt"] @@ -129,9 +127,7 @@ async def test_async_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - assert ( - await files.get_info(symlink_link) - ).symlink_target == symlink_target + assert (await files.get_info(symlink_link)).symlink_target == symlink_target broken_target = f"{base_dir}/symlink-broken/missing-target.txt" broken_link = f"{base_dir}/symlink-broken/link.txt" @@ -142,33 +138,20 @@ async def test_async_sandbox_files_e2e(): ) assert result.exit_code == 0 assert await files.exists(broken_link) is True - assert ( - await files.get_info(broken_link) - ).symlink_target == broken_target + assert (await files.get_info(broken_link)).symlink_target == broken_target read_path = f"{base_dir}/read/readme.txt" await files.write_text(read_path, "hello from sdk files") assert await files.read(read_path) == "hello from sdk files" - assert ( - await files.read(read_path, format="text", offset=6, length=4) - == "from" - ) - assert ( - await files.read(read_path, format="bytes") - == b"hello from sdk files" - ) - assert ( - await files.read(read_path, format="blob") - == b"hello from sdk files" - ) + assert await files.read(read_path, format="text", offset=6, length=4) == "from" + assert await files.read(read_path, format="bytes") == b"hello from sdk files" + assert await files.read(read_path, format="blob") == b"hello from sdk files" assert ( _read_stream_text(await files.read(read_path, format="stream")) == "hello from sdk files" ) - single = await files.write( - f"{base_dir}/write/single.txt", "single file" - ) + single = await files.write(f"{base_dir}/write/single.txt", "single file") assert single.name == "single.txt" assert single.path == f"{base_dir}/write/single.txt" assert await files.read_text(single.path) == "single file" @@ -186,9 +169,7 @@ async def test_async_sandbox_files_e2e(): ] ) assert [entry.name for entry in batch] == ["batch-a.txt", "batch-b.bin"] - assert ( - await files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" - ) + assert await files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" assert await files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( [1, 2, 3, 4] ) @@ -226,18 +207,12 @@ async def test_async_sandbox_files_e2e(): _bash_exec(f'ln -sfn "{renamed_path}" "{link_path}"') ) assert result.exit_code == 0 - copied_link = await files.copy( - source=link_path, destination=copied_link_path - ) + copied_link = await files.copy(source=link_path, destination=copied_link_path) assert copied_link.path == copied_link_path - assert ( - await files.get_info(copied_link_path) - ).symlink_target == renamed_path + assert (await files.get_info(copied_link_path)).symlink_target == renamed_path renamed_link = await files.rename(copied_link_path, renamed_link_path) assert renamed_link.path == renamed_link_path - assert ( - await files.get_info(renamed_link_path) - ).symlink_target == renamed_path + assert (await files.get_info(renamed_link_path)).symlink_target == renamed_path target_dir = f"{base_dir}/rename-dir/target-dir" link_dir = f"{base_dir}/rename-dir/link-dir" @@ -248,9 +223,7 @@ async def test_async_sandbox_files_e2e(): assert result.exit_code == 0 renamed = await files.rename(link_dir, renamed_link_dir) assert renamed.path == renamed_link_dir - assert ( - await files.get_info(renamed_link_dir) - ).symlink_target == target_dir + assert (await files.get_info(renamed_link_dir)).symlink_target == target_dir assert [ entry.path for entry in await files.list(renamed_link_dir, depth=1) ] == [f"{target_dir}/child.txt"] @@ -265,15 +238,11 @@ async def test_async_sandbox_files_e2e(): _bash_exec(f'cd "{nested_dir}" && ln -sfn "target.txt" "link.txt"') ) assert result.exit_code == 0 - await files.copy( - source=source_dir, destination=destination_dir, recursive=True - ) + await files.copy(source=source_dir, destination=destination_dir, recursive=True) copied_target = f"{destination_dir}/nested/target.txt" copied_link = f"{destination_dir}/nested/link.txt" assert await files.read_text(copied_target) == "payload" - assert ( - await files.get_info(copied_link) - ).symlink_target == copied_target + assert (await files.get_info(copied_link)).symlink_target == copied_target loop_dir = f"{base_dir}/loop-list" loop_nested_dir = f"{loop_dir}/nested" @@ -298,13 +267,9 @@ async def test_async_sandbox_files_e2e(): result = await sandbox.exec(_bash_exec(f'cd "{nested_dir}" && ln -sfn .. loop')) assert result.exit_code == 0 destination_dir = f"{base_dir}/loop-copy/destination" - await files.copy( - source=source_dir, destination=destination_dir, recursive=True - ) + await files.copy(source=source_dir, destination=destination_dir, recursive=True) copied_loop = f"{destination_dir}/nested/loop" - assert ( - await files.get_info(copied_loop) - ).symlink_target == destination_dir + assert (await files.get_info(copied_loop)).symlink_target == destination_dir assert not any( "/loop/" in entry.path for entry in await files.list(destination_dir, depth=4) @@ -321,9 +286,7 @@ async def test_async_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - await files.copy( - source=source, destination=destination_link, overwrite=True - ) + await files.copy(source=source, destination=destination_link, overwrite=True) assert await files.read_text(destination_link) == "source payload" assert await files.read_text(existing_target) == "existing target" assert (await files.get_info(destination_link)).symlink_target is None @@ -344,17 +307,9 @@ async def test_async_sandbox_files_e2e(): destination=move_destination_link, overwrite=True, ) - assert ( - await files.read_text(move_destination_link) - == "move source payload" - ) - assert ( - await files.read_text(move_existing_target) - == "move existing target" - ) - assert ( - await files.get_info(move_destination_link) - ).symlink_target is None + assert await files.read_text(move_destination_link) == "move source payload" + assert await files.read_text(move_existing_target) == "move existing target" + assert (await files.get_info(move_destination_link)).symlink_target is None assert await files.exists(move_source) is False chmod_path = f"{base_dir}/chmod/file.txt" @@ -423,20 +378,21 @@ async def test_async_sandbox_files_e2e(): fixture = await _create_parent_symlink_escape_fixture( sandbox, base_dir, "parent-escape-read" ) - assert ( - await files.read_text(fixture["escaped_file"]) == "outside secret" - ) + assert await files.read_text(fixture["escaped_file"]) == "outside secret" assert (await files.download(fixture["escaped_file"])).decode( "utf-8" ) == "outside secret" assert [ - entry.path - for entry in await files.list(fixture["link_dir"], depth=1) + entry.path for entry in await files.list(fixture["link_dir"], depth=1) ] == [f"{fixture['outside_dir']}/secret.txt"] seen = asyncio.get_running_loop().create_future() async def on_parent_event(event): - if event.type in {"create", "write"} and event.name == "fresh.txt" and not seen.done(): + if ( + event.type in {"create", "write"} + and event.name == "fresh.txt" + and not seen.done() + ): seen.set_result(event.name) handle = await files.watch_dir(fixture["link_dir"], on_parent_event) @@ -492,14 +448,16 @@ async def on_parent_event(event): seen = asyncio.get_running_loop().create_future() async def on_link_event(event): - if event.type in {"create", "write"} and event.name == "file.txt" and not seen.done(): + if ( + event.type in {"create", "write"} + and event.name == "file.txt" + and not seen.done() + ): seen.set_result(event.name) handle = await files.watch_dir(link, on_link_event) try: - await files.write_text( - f"{target_dir}/file.txt", "watch through link" - ) + await files.write_text(f"{target_dir}/file.txt", "watch through link") assert await _await_future(seen) == "file.txt" finally: await handle.stop() @@ -533,9 +491,7 @@ async def on_recursive(event): ) try: await files.write_text(f"{watch_dir}/direct.txt", "watch me") - await files.write_text( - f"{watch_dir}/nested/recursive.txt", "watch me too" - ) + await files.write_text(f"{watch_dir}/nested/recursive.txt", "watch me too") assert await _await_future(direct_future) == "direct.txt" assert await _await_future(recursive_future) == "nested/recursive.txt" finally: @@ -544,9 +500,7 @@ async def on_recursive(event): await expect_hyperbrowser_error_async( "watch missing directory", - lambda: files.watch_dir( - f"{base_dir}/watch-missing", lambda event: None - ), + lambda: files.watch_dir(f"{base_dir}/watch-missing", lambda event: None), status_code=404, service="runtime", retryable=False, diff --git a/tests/sandbox/e2e/test_files.py b/tests/sandbox/e2e/test_files.py index bea7d21d..4844f23a 100644 --- a/tests/sandbox/e2e/test_files.py +++ b/tests/sandbox/e2e/test_files.py @@ -90,9 +90,7 @@ def test_sandbox_files_e2e(): files.make_dir(f"{list_dir}/nested/inner", parents=True) files.write_text(f"{list_dir}/root.txt", "root") files.write_text(f"{list_dir}/nested/child.txt", "child") - files.write_text( - f"{list_dir}/nested/inner/grandchild.txt", "grandchild" - ) + files.write_text(f"{list_dir}/nested/inner/grandchild.txt", "grandchild") depth_one = files.list(list_dir, depth=1) assert [entry.name for entry in depth_one] == ["nested", "root.txt"] @@ -114,9 +112,7 @@ def test_sandbox_files_e2e(): result = sandbox.exec(_bash_exec(f'ln -sfn "{target}" "{link}"')) assert result.exit_code == 0 link_entry = next( - entry - for entry in files.list(symlink_dir, depth=1) - if entry.path == link + entry for entry in files.list(symlink_dir, depth=1) if entry.path == link ) assert link_entry.symlink_target == target @@ -145,9 +141,7 @@ def test_sandbox_files_e2e(): read_path = f"{base_dir}/read/readme.txt" files.write_text(read_path, "hello from sdk files") assert files.read(read_path) == "hello from sdk files" - assert ( - files.read(read_path, format="text", offset=6, length=4) == "from" - ) + assert files.read(read_path, format="text", offset=6, length=4) == "from" assert files.read(read_path, format="bytes") == b"hello from sdk files" assert files.read(read_path, format="blob") == b"hello from sdk files" assert ( @@ -174,9 +168,7 @@ def test_sandbox_files_e2e(): ) assert [entry.name for entry in batch] == ["batch-a.txt", "batch-b.bin"] assert files.read_text(f"{base_dir}/write/batch-a.txt") == "batch-a" - assert files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes( - [1, 2, 3, 4] - ) + assert files.read_bytes(f"{base_dir}/write/batch-b.bin") == bytes([1, 2, 3, 4]) text_path = f"{base_dir}/write-options/text.txt" files.write_text(text_path, "hello", mode="0640") @@ -192,9 +184,7 @@ def test_sandbox_files_e2e(): transfer_path = f"{base_dir}/transfer/upload.txt" uploaded = files.upload(transfer_path, "uploaded from sdk") assert uploaded.bytes_written > 0 - assert ( - files.download(transfer_path).decode("utf-8") == "uploaded from sdk" - ) + assert files.download(transfer_path).decode("utf-8") == "uploaded from sdk" file_path = f"{base_dir}/rename/hello.txt" renamed_path = f"{base_dir}/rename/hello-renamed.txt" @@ -226,9 +216,9 @@ def test_sandbox_files_e2e(): renamed = files.rename(link_dir, renamed_link_dir) assert renamed.path == renamed_link_dir assert files.get_info(renamed_link_dir).symlink_target == target_dir - assert [ - entry.path for entry in files.list(renamed_link_dir, depth=1) - ] == [f"{target_dir}/child.txt"] + assert [entry.path for entry in files.list(renamed_link_dir, depth=1)] == [ + f"{target_dir}/child.txt" + ] source_dir = f"{base_dir}/copy-tree/source" nested_dir = f"{source_dir}/nested" @@ -240,9 +230,7 @@ def test_sandbox_files_e2e(): _bash_exec(f'cd "{nested_dir}" && ln -sfn "target.txt" "link.txt"') ) assert result.exit_code == 0 - files.copy( - source=source_dir, destination=destination_dir, recursive=True - ) + files.copy(source=source_dir, destination=destination_dir, recursive=True) copied_target = f"{destination_dir}/nested/target.txt" copied_link = f"{destination_dir}/nested/link.txt" assert files.read_text(copied_target) == "payload" @@ -258,9 +246,7 @@ def test_sandbox_files_e2e(): loop_paths = [entry.path for entry in loop_entries] assert f"{loop_nested_dir}/loop" in loop_paths assert not any("/loop/" in path for path in loop_paths) - assert ( - files.get_info(f"{loop_nested_dir}/loop").symlink_target == loop_dir - ) + assert files.get_info(f"{loop_nested_dir}/loop").symlink_target == loop_dir source_dir = f"{base_dir}/loop-copy/source" nested_dir = f"{source_dir}/nested" @@ -269,14 +255,11 @@ def test_sandbox_files_e2e(): result = sandbox.exec(_bash_exec(f'cd "{nested_dir}" && ln -sfn .. loop')) assert result.exit_code == 0 destination_dir = f"{base_dir}/loop-copy/destination" - files.copy( - source=source_dir, destination=destination_dir, recursive=True - ) + files.copy(source=source_dir, destination=destination_dir, recursive=True) copied_loop = f"{destination_dir}/nested/loop" assert files.get_info(copied_loop).symlink_target == destination_dir assert not any( - "/loop/" in entry.path - for entry in files.list(destination_dir, depth=4) + "/loop/" in entry.path for entry in files.list(destination_dir, depth=4) ) source = f"{base_dir}/copy-overwrite/source.txt" @@ -384,12 +367,11 @@ def test_sandbox_files_e2e(): ) assert files.read_text(fixture["escaped_file"]) == "outside secret" assert ( - files.download(fixture["escaped_file"]).decode("utf-8") - == "outside secret" + files.download(fixture["escaped_file"]).decode("utf-8") == "outside secret" ) - assert [ - entry.path for entry in files.list(fixture["link_dir"], depth=1) - ] == [f"{fixture['outside_dir']}/secret.txt"] + assert [entry.path for entry in files.list(fixture["link_dir"], depth=1)] == [ + f"{fixture['outside_dir']}/secret.txt" + ] seen = Queue(maxsize=1) handle = files.watch_dir( fixture["link_dir"], @@ -400,9 +382,7 @@ def test_sandbox_files_e2e(): ), ) try: - files.write_text( - f"{fixture['outside_dir']}/fresh.txt", "watch parent link" - ) + files.write_text(f"{fixture['outside_dir']}/fresh.txt", "watch parent link") assert _await_queue_value(seen) == "fresh.txt" finally: handle.stop() @@ -445,9 +425,7 @@ def test_sandbox_files_e2e(): ) ) assert result.exit_code == 0 - assert [entry.path for entry in files.list(link, depth=1)] == [ - target_file - ] + assert [entry.path for entry in files.list(link, depth=1)] == [target_file] seen = Queue(maxsize=1) handle = files.watch_dir( link, @@ -479,16 +457,15 @@ def test_sandbox_files_e2e(): watch_dir, lambda event: ( recursive_event.put_nowait(event.name) - if event.type in {"create", "write"} and event.name == "nested/recursive.txt" + if event.type in {"create", "write"} + and event.name == "nested/recursive.txt" else None ), recursive=True, ) try: files.write_text(f"{watch_dir}/direct.txt", "watch me") - files.write_text( - f"{watch_dir}/nested/recursive.txt", "watch me too" - ) + files.write_text(f"{watch_dir}/nested/recursive.txt", "watch me too") assert _await_queue_value(direct_event) == "direct.txt" assert _await_queue_value(recursive_event) == "nested/recursive.txt" finally: @@ -497,9 +474,7 @@ def test_sandbox_files_e2e(): expect_hyperbrowser_error( "watch missing directory", - lambda: files.watch_dir( - f"{base_dir}/watch-missing", lambda event: None - ), + lambda: files.watch_dir(f"{base_dir}/watch-missing", lambda event: None), status_code=404, service="runtime", retryable=False, diff --git a/tests/test_volume_wire_contract.py b/tests/test_volume_wire_contract.py index efc17b19..67ae9772 100644 --- a/tests/test_volume_wire_contract.py +++ b/tests/test_volume_wire_contract.py @@ -1,6 +1,8 @@ import pytest -from hyperbrowser.client.managers.async_manager.volume import VolumeManager as AsyncVolumeManager +from hyperbrowser.client.managers.async_manager.volume import ( + VolumeManager as AsyncVolumeManager, +) from hyperbrowser.client.managers.sync_manager.volume import VolumeManager from hyperbrowser.models import CreateVolumeParams, Volume From 28c872956d2d0fd3c24fff1baaf3dffb1f23883e Mon Sep 17 00:00:00 2001 From: Devin Date: Fri, 10 Apr 2026 14:31:13 -0700 Subject: [PATCH 4/4] remove dupes --- hyperbrowser/models/_parsers.py | 11 +++++++++++ hyperbrowser/models/sandbox.py | 11 +---------- hyperbrowser/models/volume.py | 10 +--------- 3 files changed, 13 insertions(+), 19 deletions(-) create mode 100644 hyperbrowser/models/_parsers.py diff --git a/hyperbrowser/models/_parsers.py b/hyperbrowser/models/_parsers.py new file mode 100644 index 00000000..1f5b8d6b --- /dev/null +++ b/hyperbrowser/models/_parsers.py @@ -0,0 +1,11 @@ +from typing import Any + + +def _parse_optional_int(value: Any): + if value is None or isinstance(value, int): + return value + if isinstance(value, str) and value.strip() == "": + return None + if isinstance(value, str): + return int(value) + return value diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index ea7f869b..aa7faf0a 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from ._parsers import _parse_optional_int from .session import SessionLaunchState, SessionStatus SandboxStatus = SessionStatus @@ -38,16 +39,6 @@ def _parse_optional_datetime(value): return value -def _parse_optional_int(value): - if value is None or isinstance(value, int): - return value - if isinstance(value, str) and value.strip() == "": - return None - if isinstance(value, str): - return int(value) - return value - - def _parse_optional_datetime_from_millis(value): if value in (None, ""): return None diff --git a/hyperbrowser/models/volume.py b/hyperbrowser/models/volume.py index 56b6a913..1287642a 100644 --- a/hyperbrowser/models/volume.py +++ b/hyperbrowser/models/volume.py @@ -2,15 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator - -def _parse_optional_int(value): - if value is None or isinstance(value, int): - return value - if isinstance(value, str) and value.strip() == "": - return None - if isinstance(value, str): - return int(value) - return value +from ._parsers import _parse_optional_int class VolumeBaseModel(BaseModel):