moru 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. moru/__init__.py +8 -0
  2. moru/api/__init__.py +4 -0
  3. moru/api/client/__init__.py +1 -1
  4. moru/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +4 -0
  5. moru/api/client/api/sandboxes/get_sandboxes.py +4 -0
  6. moru/api/client/api/sandboxes/get_sandboxes_metrics.py +5 -1
  7. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +4 -0
  8. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +67 -23
  9. moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +5 -0
  10. moru/api/client/api/sandboxes/get_v2_sandbox_runs.py +218 -0
  11. moru/api/client/api/sandboxes/get_v2_sandboxes.py +5 -2
  12. moru/api/client/api/sandboxes/post_sandboxes.py +4 -0
  13. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +6 -0
  14. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +5 -0
  15. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +3 -0
  16. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +5 -0
  17. moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +4 -0
  18. moru/api/client/api/templates/delete_templates_template_id.py +3 -0
  19. moru/api/client/api/templates/get_templates.py +3 -0
  20. moru/api/client/api/templates/get_templates_template_id.py +3 -0
  21. moru/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py +276 -0
  22. moru/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +23 -4
  23. moru/api/client/api/templates/get_templates_template_id_files_hash.py +5 -0
  24. moru/api/client/api/templates/patch_templates_template_id.py +4 -0
  25. moru/api/client/api/templates/post_templates.py +4 -0
  26. moru/api/client/api/templates/post_templates_template_id.py +3 -0
  27. moru/api/client/api/templates/post_templates_template_id_builds_build_id.py +3 -0
  28. moru/api/client/api/templates/post_v2_templates.py +4 -0
  29. moru/api/client/api/templates/post_v3_templates.py +4 -0
  30. moru/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +3 -0
  31. moru/api/client/models/__init__.py +30 -0
  32. moru/api/client/models/admin_sandbox_kill_result.py +67 -0
  33. moru/api/client/models/build_log_entry.py +1 -1
  34. moru/api/client/models/create_volume_request.py +59 -0
  35. moru/api/client/models/file_info.py +105 -0
  36. moru/api/client/models/file_info_type.py +9 -0
  37. moru/api/client/models/file_list_response.py +84 -0
  38. moru/api/client/models/logs_direction.py +9 -0
  39. moru/api/client/models/logs_source.py +9 -0
  40. moru/api/client/models/machine_info.py +83 -0
  41. moru/api/client/models/new_sandbox.py +19 -0
  42. moru/api/client/models/node.py +10 -0
  43. moru/api/client/models/node_detail.py +10 -0
  44. moru/api/client/models/sandbox_log_entry.py +9 -9
  45. moru/api/client/models/sandbox_log_event_type.py +11 -0
  46. moru/api/client/models/sandbox_run.py +130 -0
  47. moru/api/client/models/sandbox_run_end_reason.py +11 -0
  48. moru/api/client/models/sandbox_run_status.py +10 -0
  49. moru/api/client/models/template_build_logs_response.py +73 -0
  50. moru/api/client/models/upload_response.py +67 -0
  51. moru/api/client/models/volume.py +105 -0
  52. moru/sandbox/mcp.py +835 -6
  53. moru/sandbox_async/commands/command.py +5 -1
  54. moru/sandbox_async/filesystem/filesystem.py +5 -1
  55. moru/sandbox_async/main.py +21 -0
  56. moru/sandbox_async/sandbox_api.py +17 -11
  57. moru/sandbox_sync/filesystem/filesystem.py +5 -1
  58. moru/sandbox_sync/main.py +21 -0
  59. moru/sandbox_sync/sandbox_api.py +17 -11
  60. moru/volume/__init__.py +11 -0
  61. moru/volume/types.py +83 -0
  62. moru/volume/volume_api.py +330 -0
  63. moru/volume_async/__init__.py +5 -0
  64. moru/volume_async/main.py +327 -0
  65. moru/volume_async/volume_api.py +290 -0
  66. moru/volume_sync/__init__.py +5 -0
  67. moru/volume_sync/main.py +325 -0
  68. moru-0.2.0.dist-info/METADATA +122 -0
  69. {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/RECORD +71 -46
  70. {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/WHEEL +1 -1
  71. moru-0.1.0.dist-info/METADATA +0 -63
  72. {moru-0.1.0.dist-info/licenses → moru-0.2.0.dist-info}/LICENSE +0 -0
@@ -15,7 +15,11 @@ from moru.envd.versions import ENVD_COMMANDS_STDIN
15
15
  from moru.exceptions import SandboxException
16
16
  from moru.sandbox.commands.main import ProcessInfo
17
17
  from moru.sandbox.commands.command_handle import CommandResult
18
- from moru.sandbox_async.commands.command_handle import AsyncCommandHandle, Stderr, Stdout
18
+ from moru.sandbox_async.commands.command_handle import (
19
+ AsyncCommandHandle,
20
+ Stderr,
21
+ Stdout,
22
+ )
19
23
  from moru.sandbox_async.utils import OutputHandler
20
24
 
21
25
 
@@ -16,7 +16,11 @@ from moru.envd.api import ENVD_API_FILES_ROUTE, ahandle_envd_api_exception
16
16
  from moru.envd.filesystem import filesystem_connect, filesystem_pb2
17
17
  from moru.envd.rpc import authentication_header, handle_rpc_exception
18
18
  from moru.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER
19
- from moru.exceptions import SandboxException, TemplateException, InvalidArgumentException
19
+ from moru.exceptions import (
20
+ SandboxException,
21
+ TemplateException,
22
+ InvalidArgumentException,
23
+ )
20
24
  from moru.sandbox.filesystem.filesystem import (
21
25
  WriteInfo,
22
26
  EntryInfo,
@@ -16,6 +16,7 @@ from moru.exceptions import SandboxException, format_request_timeout_error
16
16
  from moru.sandbox.main import SandboxOpts
17
17
  from moru.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts
18
18
  from moru.sandbox.utils import class_method_variant
19
+ from moru.volume.volume_api import validate_mount_path
19
20
  from moru.sandbox_async.commands.command import Commands
20
21
  from moru.sandbox_async.commands.pty import Pty
21
22
  from moru.sandbox_async.filesystem.filesystem import Filesystem
@@ -153,6 +154,8 @@ class AsyncSandbox(SandboxApi):
153
154
  allow_internet_access: bool = True,
154
155
  mcp: Optional[McpServer] = None,
155
156
  network: Optional[SandboxNetworkOpts] = None,
157
+ volume_id: Optional[str] = None,
158
+ volume_mount_path: Optional[str] = None,
156
159
  **opts: Unpack[ApiParams],
157
160
  ) -> Self:
158
161
  """
@@ -168,11 +171,23 @@ class AsyncSandbox(SandboxApi):
168
171
  :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
169
172
  :param mcp: MCP server to enable in the sandbox
170
173
  :param network: Sandbox network configuration
174
+ :param volume_id: Volume ID to attach (e.g., vol_abc123). Requires volume_mount_path.
175
+ :param volume_mount_path: Mount path inside sandbox. Required if volume_id is provided.
176
+ Must start with /workspace/, /data/, /mnt/, or /volumes/.
177
+ Note: Mounting overlays any existing files/directories at the mount path.
171
178
 
172
179
  :return: A Sandbox instance for the new sandbox
173
180
 
174
181
  Use this method instead of using the constructor to create a new sandbox.
175
182
  """
183
+ # Validate volume parameters
184
+ if volume_id and not volume_mount_path:
185
+ raise ValueError("volume_mount_path is required when volume_id is provided")
186
+ if volume_mount_path and not volume_id:
187
+ raise ValueError("volume_id is required when volume_mount_path is provided")
188
+ if volume_mount_path:
189
+ validate_mount_path(volume_mount_path)
190
+
176
191
  if not template and mcp is not None:
177
192
  template = cls.default_mcp_template
178
193
  elif not template:
@@ -188,6 +203,8 @@ class AsyncSandbox(SandboxApi):
188
203
  allow_internet_access=allow_internet_access,
189
204
  mcp=mcp,
190
205
  network=network,
206
+ volume_id=volume_id,
207
+ volume_mount_path=volume_mount_path,
191
208
  **opts,
192
209
  )
193
210
 
@@ -680,6 +697,8 @@ class AsyncSandbox(SandboxApi):
680
697
  secure: bool,
681
698
  mcp: Optional[McpServer] = None,
682
699
  network: Optional[SandboxNetworkOpts] = None,
700
+ volume_id: Optional[str] = None,
701
+ volume_mount_path: Optional[str] = None,
683
702
  **opts: Unpack[ApiParams],
684
703
  ) -> Self:
685
704
  extra_sandbox_headers = {}
@@ -702,6 +721,8 @@ class AsyncSandbox(SandboxApi):
702
721
  allow_internet_access=allow_internet_access,
703
722
  mcp=mcp,
704
723
  network=network,
724
+ volume_id=volume_id,
725
+ volume_mount_path=volume_mount_path,
705
726
  **opts,
706
727
  )
707
728
 
@@ -159,23 +159,29 @@ class SandboxApi(SandboxBase):
159
159
  secure: bool,
160
160
  mcp: Optional[McpServer] = None,
161
161
  network: Optional[SandboxNetworkOpts] = None,
162
+ volume_id: Optional[str] = None,
163
+ volume_mount_path: Optional[str] = None,
162
164
  **opts: Unpack[ApiParams],
163
165
  ) -> SandboxCreateResponse:
164
166
  config = ConnectionConfig(**opts)
165
167
 
166
168
  api_client = get_api_client(config)
169
+ new_sandbox = NewSandbox(
170
+ template_id=template,
171
+ auto_pause=auto_pause,
172
+ metadata=metadata or {},
173
+ timeout=timeout,
174
+ env_vars=env_vars or {},
175
+ mcp=mcp or UNSET,
176
+ secure=secure,
177
+ allow_internet_access=allow_internet_access,
178
+ network=SandboxNetworkConfig(**network) if network else UNSET,
179
+ volume_id=volume_id or UNSET,
180
+ volume_mount_path=volume_mount_path or UNSET,
181
+ )
182
+
167
183
  res = await post_sandboxes.asyncio_detailed(
168
- body=NewSandbox(
169
- template_id=template,
170
- auto_pause=auto_pause,
171
- metadata=metadata or {},
172
- timeout=timeout,
173
- env_vars=env_vars or {},
174
- mcp=mcp or UNSET,
175
- secure=secure,
176
- allow_internet_access=allow_internet_access,
177
- network=SandboxNetworkConfig(**network) if network else UNSET,
178
- ),
184
+ body=new_sandbox,
179
185
  client=api_client,
180
186
  )
181
187
 
@@ -9,7 +9,11 @@ import httpx
9
9
  from packaging.version import Version
10
10
 
11
11
  from moru.envd.versions import ENVD_VERSION_RECURSIVE_WATCH, ENVD_DEFAULT_USER
12
- from moru.exceptions import SandboxException, TemplateException, InvalidArgumentException
12
+ from moru.exceptions import (
13
+ SandboxException,
14
+ TemplateException,
15
+ InvalidArgumentException,
16
+ )
13
17
  from moru.connection_config import (
14
18
  ConnectionConfig,
15
19
  Username,
moru/sandbox_sync/main.py CHANGED
@@ -16,6 +16,7 @@ from moru.exceptions import SandboxException, format_request_timeout_error
16
16
  from moru.sandbox.main import SandboxOpts
17
17
  from moru.sandbox.sandbox_api import McpServer, SandboxMetrics, SandboxNetworkOpts
18
18
  from moru.sandbox.utils import class_method_variant
19
+ from moru.volume.volume_api import validate_mount_path
19
20
  from moru.sandbox_sync.commands.command import Commands
20
21
  from moru.sandbox_sync.commands.pty import Pty
21
22
  from moru.sandbox_sync.filesystem.filesystem import Filesystem
@@ -151,6 +152,8 @@ class Sandbox(SandboxApi):
151
152
  allow_internet_access: bool = True,
152
153
  mcp: Optional[McpServer] = None,
153
154
  network: Optional[SandboxNetworkOpts] = None,
155
+ volume_id: Optional[str] = None,
156
+ volume_mount_path: Optional[str] = None,
154
157
  **opts: Unpack[ApiParams],
155
158
  ) -> Self:
156
159
  """
@@ -166,11 +169,23 @@ class Sandbox(SandboxApi):
166
169
  :param allow_internet_access: Allow sandbox to access the internet, defaults to `True`. If set to `False`, it works the same as setting network `deny_out` to `[0.0.0.0/0]`.
167
170
  :param mcp: MCP server to enable in the sandbox
168
171
  :param network: Sandbox network configuration
172
+ :param volume_id: Volume ID to attach (e.g., vol_abc123). Requires volume_mount_path.
173
+ :param volume_mount_path: Mount path inside sandbox. Required if volume_id is provided.
174
+ Must start with /workspace/, /data/, /mnt/, or /volumes/.
175
+ Note: Mounting overlays any existing files/directories at the mount path.
169
176
 
170
177
  :return: A Sandbox instance for the new sandbox
171
178
 
172
179
  Use this method instead of using the constructor to create a new sandbox.
173
180
  """
181
+ # Validate volume parameters
182
+ if volume_id and not volume_mount_path:
183
+ raise ValueError("volume_mount_path is required when volume_id is provided")
184
+ if volume_mount_path and not volume_id:
185
+ raise ValueError("volume_id is required when volume_mount_path is provided")
186
+ if volume_mount_path:
187
+ validate_mount_path(volume_mount_path)
188
+
174
189
  if not template and mcp is not None:
175
190
  template = cls.default_mcp_template
176
191
  elif not template:
@@ -186,6 +201,8 @@ class Sandbox(SandboxApi):
186
201
  allow_internet_access=allow_internet_access,
187
202
  mcp=mcp,
188
203
  network=network,
204
+ volume_id=volume_id,
205
+ volume_mount_path=volume_mount_path,
189
206
  **opts,
190
207
  )
191
208
 
@@ -672,6 +689,8 @@ class Sandbox(SandboxApi):
672
689
  allow_internet_access: bool,
673
690
  mcp: Optional[McpServer] = None,
674
691
  network: Optional[SandboxNetworkOpts] = None,
692
+ volume_id: Optional[str] = None,
693
+ volume_mount_path: Optional[str] = None,
675
694
  **opts: Unpack[ApiParams],
676
695
  ) -> Self:
677
696
  extra_sandbox_headers = {}
@@ -694,6 +713,8 @@ class Sandbox(SandboxApi):
694
713
  allow_internet_access=allow_internet_access,
695
714
  mcp=mcp,
696
715
  network=network,
716
+ volume_id=volume_id,
717
+ volume_mount_path=volume_mount_path,
697
718
  **opts,
698
719
  )
699
720
 
@@ -158,23 +158,29 @@ class SandboxApi(SandboxBase):
158
158
  secure: bool,
159
159
  mcp: Optional[McpServer] = None,
160
160
  network: Optional[SandboxNetworkOpts] = None,
161
+ volume_id: Optional[str] = None,
162
+ volume_mount_path: Optional[str] = None,
161
163
  **opts: Unpack[ApiParams],
162
164
  ) -> SandboxCreateResponse:
163
165
  config = ConnectionConfig(**opts)
164
166
 
165
167
  api_client = get_api_client(config)
168
+ new_sandbox = NewSandbox(
169
+ template_id=template,
170
+ auto_pause=auto_pause,
171
+ metadata=metadata or {},
172
+ timeout=timeout,
173
+ env_vars=env_vars or {},
174
+ mcp=mcp or UNSET,
175
+ secure=secure,
176
+ allow_internet_access=allow_internet_access,
177
+ network=SandboxNetworkConfig(**network) if network else UNSET,
178
+ volume_id=volume_id or UNSET,
179
+ volume_mount_path=volume_mount_path or UNSET,
180
+ )
181
+
166
182
  res = post_sandboxes.sync_detailed(
167
- body=NewSandbox(
168
- template_id=template,
169
- auto_pause=auto_pause,
170
- metadata=metadata or {},
171
- timeout=timeout,
172
- env_vars=env_vars or {},
173
- mcp=mcp or UNSET,
174
- secure=secure,
175
- allow_internet_access=allow_internet_access,
176
- network=SandboxNetworkConfig(**network) if network else UNSET,
177
- ),
183
+ body=new_sandbox,
178
184
  client=api_client,
179
185
  )
180
186
 
@@ -0,0 +1,11 @@
1
+ """Volume module for persistent storage."""
2
+
3
+ from .types import FileInfo, FileType, VolumeInfo
4
+ from .volume_api import VolumeApi
5
+
6
+ __all__ = [
7
+ "FileInfo",
8
+ "FileType",
9
+ "VolumeApi",
10
+ "VolumeInfo",
11
+ ]
moru/volume/types.py ADDED
@@ -0,0 +1,83 @@
1
+ """Volume type definitions."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Optional
7
+
8
+
9
+ class FileType(str, Enum):
10
+ """Type of file system entry."""
11
+
12
+ FILE = "file"
13
+ DIRECTORY = "directory"
14
+
15
+
16
+ @dataclass
17
+ class FileInfo:
18
+ """Information about a file or directory in a volume."""
19
+
20
+ name: str
21
+ """File or directory name."""
22
+
23
+ path: str
24
+ """Full path within volume."""
25
+
26
+ type: FileType
27
+ """Entry type (file or directory)."""
28
+
29
+ size: Optional[int] = None
30
+ """File size in bytes (only for files)."""
31
+
32
+ modified_at: Optional[datetime] = None
33
+ """Last modification time."""
34
+
35
+ @classmethod
36
+ def _from_api_response(cls, data: dict) -> "FileInfo":
37
+ """Create FileInfo from API response data."""
38
+ return cls(
39
+ name=data["name"],
40
+ path=data["path"],
41
+ type=FileType(data["type"]),
42
+ size=data.get("size"),
43
+ modified_at=(
44
+ datetime.fromisoformat(data["modifiedAt"].replace("Z", "+00:00"))
45
+ if data.get("modifiedAt")
46
+ else None
47
+ ),
48
+ )
49
+
50
+
51
+ @dataclass
52
+ class VolumeInfo:
53
+ """Information about a volume."""
54
+
55
+ volume_id: str
56
+ """Unique volume identifier."""
57
+
58
+ name: str
59
+ """Volume name."""
60
+
61
+ total_size_bytes: int
62
+ """Total size of files in volume (bytes)."""
63
+
64
+ total_file_count: int
65
+ """Total number of files in volume."""
66
+
67
+ created_at: datetime
68
+ """When the volume was created."""
69
+
70
+ updated_at: datetime
71
+ """When the volume was last updated."""
72
+
73
+ @classmethod
74
+ def _from_api_response(cls, data: dict) -> "VolumeInfo":
75
+ """Create VolumeInfo from API response data."""
76
+ return cls(
77
+ volume_id=data["volumeID"],
78
+ name=data["name"],
79
+ total_size_bytes=data.get("totalSizeBytes", 0),
80
+ total_file_count=data.get("totalFileCount", 0),
81
+ created_at=datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00")),
82
+ updated_at=datetime.fromisoformat(data["updatedAt"].replace("Z", "+00:00")),
83
+ )
@@ -0,0 +1,330 @@
1
+ """Base VolumeApi class with static methods for volume operations."""
2
+
3
+ from typing import List, Optional, Tuple
4
+
5
+ from typing_extensions import Unpack
6
+
7
+ from moru.api import ApiClient, handle_api_exception
8
+ from moru.api.client_sync import get_api_client
9
+ from moru.connection_config import ApiParams, ConnectionConfig
10
+ from moru.exceptions import NotFoundException
11
+
12
+ from .types import FileInfo, VolumeInfo
13
+
14
+
15
+ # Allowed mount path prefixes for sandbox volume attachment
16
+ ALLOWED_MOUNT_PREFIXES = ("/workspace/", "/data/", "/mnt/", "/volumes/")
17
+
18
+
19
+ def validate_mount_path(path: str) -> None:
20
+ """
21
+ Validate volume mount path.
22
+
23
+ :param path: Mount path to validate
24
+ :raises ValueError: If path is invalid
25
+
26
+ Allowed prefixes: /workspace/, /data/, /mnt/, /volumes/
27
+ """
28
+ if not path:
29
+ raise ValueError("Mount path cannot be empty")
30
+
31
+ if not path.startswith("/"):
32
+ raise ValueError("Mount path must be absolute (start with /)")
33
+
34
+ # Check allowed prefixes
35
+ if not any(path.startswith(prefix) for prefix in ALLOWED_MOUNT_PREFIXES):
36
+ raise ValueError(
37
+ f"Mount path must start with one of: {', '.join(ALLOWED_MOUNT_PREFIXES)}"
38
+ )
39
+
40
+ # Check for directory traversal
41
+ if ".." in path:
42
+ raise ValueError("Mount path cannot contain '..'")
43
+
44
+ # Check that path has a subdirectory after prefix
45
+ # e.g., /workspace is not valid, but /workspace/data is
46
+ for prefix in ALLOWED_MOUNT_PREFIXES:
47
+ if path.startswith(prefix):
48
+ remainder = path[len(prefix) :]
49
+ # Empty remainder means path is just the prefix without trailing content
50
+ # This is actually valid - /workspace/ should work
51
+ break
52
+
53
+
54
+ class VolumeApi:
55
+ """Base class for volume API operations."""
56
+
57
+ @staticmethod
58
+ def _create_volume(
59
+ name: str,
60
+ **opts: Unpack[ApiParams],
61
+ ) -> VolumeInfo:
62
+ """
63
+ Create a new volume (idempotent).
64
+
65
+ :param name: Volume name
66
+ :return: Volume info
67
+ """
68
+ config = ConnectionConfig(**opts)
69
+ api_client = get_api_client(config)
70
+ client = api_client.get_httpx_client()
71
+
72
+ response = client.post(
73
+ f"{config.api_url}/volumes",
74
+ json={"name": name},
75
+ timeout=config.request_timeout,
76
+ )
77
+
78
+ err = handle_api_exception(response)
79
+ if err:
80
+ raise err
81
+
82
+ return VolumeInfo._from_api_response(response.json())
83
+
84
+ @staticmethod
85
+ def _get_volume(
86
+ volume_id_or_name: str,
87
+ **opts: Unpack[ApiParams],
88
+ ) -> VolumeInfo:
89
+ """
90
+ Get volume by ID or name.
91
+
92
+ :param volume_id_or_name: Volume ID (vol_xxx) or name
93
+ :return: Volume info
94
+ """
95
+ config = ConnectionConfig(**opts)
96
+ api_client = get_api_client(config)
97
+ client = api_client.get_httpx_client()
98
+
99
+ response = client.get(
100
+ f"{config.api_url}/volumes/{volume_id_or_name}",
101
+ timeout=config.request_timeout,
102
+ )
103
+
104
+ if response.status_code == 404:
105
+ raise NotFoundException(f"Volume '{volume_id_or_name}' not found")
106
+
107
+ err = handle_api_exception(response)
108
+ if err:
109
+ raise err
110
+
111
+ return VolumeInfo._from_api_response(response.json())
112
+
113
+ @staticmethod
114
+ def _list_volumes(
115
+ limit: Optional[int] = None,
116
+ next_token: Optional[str] = None,
117
+ **opts: Unpack[ApiParams],
118
+ ) -> Tuple[List[VolumeInfo], Optional[str]]:
119
+ """
120
+ List all volumes.
121
+
122
+ :param limit: Maximum number of volumes to return
123
+ :param next_token: Pagination token
124
+ :return: Tuple of (volumes, next_token)
125
+ """
126
+ config = ConnectionConfig(**opts)
127
+ api_client = get_api_client(config)
128
+ client = api_client.get_httpx_client()
129
+
130
+ params = {}
131
+ if limit is not None:
132
+ params["limit"] = limit
133
+ if next_token is not None:
134
+ params["nextToken"] = next_token
135
+
136
+ response = client.get(
137
+ f"{config.api_url}/volumes",
138
+ params=params,
139
+ timeout=config.request_timeout,
140
+ )
141
+
142
+ err = handle_api_exception(response)
143
+ if err:
144
+ raise err
145
+
146
+ volumes = [VolumeInfo._from_api_response(v) for v in response.json()]
147
+ result_next_token = response.headers.get("x-next-token")
148
+
149
+ return volumes, result_next_token
150
+
151
+ @staticmethod
152
+ def _delete_volume(
153
+ volume_id_or_name: str,
154
+ **opts: Unpack[ApiParams],
155
+ ) -> bool:
156
+ """
157
+ Delete a volume.
158
+
159
+ :param volume_id_or_name: Volume ID (vol_xxx) or name
160
+ :return: True if deleted, False if not found
161
+ """
162
+ config = ConnectionConfig(**opts)
163
+ api_client = get_api_client(config)
164
+ client = api_client.get_httpx_client()
165
+
166
+ response = client.delete(
167
+ f"{config.api_url}/volumes/{volume_id_or_name}",
168
+ timeout=config.request_timeout,
169
+ )
170
+
171
+ if response.status_code == 404:
172
+ return False
173
+
174
+ err = handle_api_exception(response)
175
+ if err:
176
+ raise err
177
+
178
+ return True
179
+
180
+ @staticmethod
181
+ def _list_files(
182
+ volume_id: str,
183
+ path: str = "/",
184
+ limit: Optional[int] = None,
185
+ next_token: Optional[str] = None,
186
+ **opts: Unpack[ApiParams],
187
+ ) -> Tuple[List[FileInfo], Optional[str]]:
188
+ """
189
+ List files in a volume.
190
+
191
+ :param volume_id: Volume ID (vol_xxx)
192
+ :param path: Directory path to list
193
+ :param limit: Maximum number of files to return
194
+ :param next_token: Pagination token
195
+ :return: Tuple of (files, next_token)
196
+ """
197
+ config = ConnectionConfig(**opts)
198
+ api_client = get_api_client(config)
199
+ client = api_client.get_httpx_client()
200
+
201
+ params = {"path": path}
202
+ if limit is not None:
203
+ params["limit"] = limit
204
+ if next_token is not None:
205
+ params["nextToken"] = next_token
206
+
207
+ response = client.get(
208
+ f"{config.api_url}/volumes/{volume_id}/files",
209
+ params=params,
210
+ timeout=config.request_timeout,
211
+ )
212
+
213
+ if response.status_code == 404:
214
+ raise NotFoundException(f"Volume '{volume_id}' not found")
215
+
216
+ err = handle_api_exception(response)
217
+ if err:
218
+ raise err
219
+
220
+ data = response.json()
221
+ files = [FileInfo._from_api_response(f) for f in data.get("files", [])]
222
+ result_next_token = data.get("nextToken")
223
+
224
+ return files, result_next_token
225
+
226
+ @staticmethod
227
+ def _upload_file(
228
+ volume_id: str,
229
+ path: str,
230
+ content: bytes,
231
+ **opts: Unpack[ApiParams],
232
+ ) -> int:
233
+ """
234
+ Upload file content to volume.
235
+
236
+ :param volume_id: Volume ID (vol_xxx)
237
+ :param path: Destination path in volume
238
+ :param content: File content as bytes
239
+ :return: Size of uploaded file
240
+ """
241
+ config = ConnectionConfig(**opts)
242
+ api_client = get_api_client(config)
243
+ client = api_client.get_httpx_client()
244
+
245
+ response = client.put(
246
+ f"{config.api_url}/volumes/{volume_id}/files/upload",
247
+ params={"path": path},
248
+ content=content,
249
+ headers={"Content-Type": "application/octet-stream"},
250
+ timeout=config.request_timeout,
251
+ )
252
+
253
+ if response.status_code == 404:
254
+ raise NotFoundException(f"Volume '{volume_id}' not found")
255
+
256
+ err = handle_api_exception(response)
257
+ if err:
258
+ raise err
259
+
260
+ return response.json().get("size", len(content))
261
+
262
+ @staticmethod
263
+ def _download_file(
264
+ volume_id: str,
265
+ path: str,
266
+ **opts: Unpack[ApiParams],
267
+ ) -> bytes:
268
+ """
269
+ Download file content from volume.
270
+
271
+ :param volume_id: Volume ID (vol_xxx)
272
+ :param path: File path in volume
273
+ :return: File content as bytes
274
+ """
275
+ config = ConnectionConfig(**opts)
276
+ api_client = get_api_client(config)
277
+ client = api_client.get_httpx_client()
278
+
279
+ response = client.get(
280
+ f"{config.api_url}/volumes/{volume_id}/files/download",
281
+ params={"path": path},
282
+ timeout=config.request_timeout,
283
+ )
284
+
285
+ if response.status_code == 404:
286
+ raise NotFoundException(f"Volume '{volume_id}' or file '{path}' not found")
287
+
288
+ err = handle_api_exception(response)
289
+ if err:
290
+ raise err
291
+
292
+ return response.content
293
+
294
+ @staticmethod
295
+ def _delete_file(
296
+ volume_id: str,
297
+ path: str,
298
+ recursive: bool = False,
299
+ **opts: Unpack[ApiParams],
300
+ ) -> bool:
301
+ """
302
+ Delete file or directory from volume.
303
+
304
+ :param volume_id: Volume ID (vol_xxx)
305
+ :param path: Path to delete
306
+ :param recursive: Delete directory recursively
307
+ :return: True if deleted
308
+ """
309
+ config = ConnectionConfig(**opts)
310
+ api_client = get_api_client(config)
311
+ client = api_client.get_httpx_client()
312
+
313
+ params: dict = {"path": path}
314
+ if recursive:
315
+ params["recursive"] = "true"
316
+
317
+ response = client.delete(
318
+ f"{config.api_url}/volumes/{volume_id}/files",
319
+ params=params,
320
+ timeout=config.request_timeout,
321
+ )
322
+
323
+ if response.status_code == 404:
324
+ raise NotFoundException(f"Volume '{volume_id}' or path '{path}' not found")
325
+
326
+ err = handle_api_exception(response)
327
+ if err:
328
+ raise err
329
+
330
+ return True
@@ -0,0 +1,5 @@
1
+ """Asynchronous Volume module."""
2
+
3
+ from .main import AsyncVolume
4
+
5
+ __all__ = ["AsyncVolume"]