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.
- moru/__init__.py +8 -0
- moru/api/__init__.py +4 -0
- moru/api/client/__init__.py +1 -1
- moru/api/client/api/sandboxes/delete_sandboxes_sandbox_id.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes_metrics.py +5 -1
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id.py +4 -0
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_logs.py +67 -23
- moru/api/client/api/sandboxes/get_sandboxes_sandbox_id_metrics.py +5 -0
- moru/api/client/api/sandboxes/get_v2_sandbox_runs.py +218 -0
- moru/api/client/api/sandboxes/get_v2_sandboxes.py +5 -2
- moru/api/client/api/sandboxes/post_sandboxes.py +4 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +6 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_pause.py +5 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_refreshes.py +3 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_resume.py +5 -0
- moru/api/client/api/sandboxes/post_sandboxes_sandbox_id_timeout.py +4 -0
- moru/api/client/api/templates/delete_templates_template_id.py +3 -0
- moru/api/client/api/templates/get_templates.py +3 -0
- moru/api/client/api/templates/get_templates_template_id.py +3 -0
- moru/api/client/api/templates/get_templates_template_id_builds_build_id_logs.py +276 -0
- moru/api/client/api/templates/get_templates_template_id_builds_build_id_status.py +23 -4
- moru/api/client/api/templates/get_templates_template_id_files_hash.py +5 -0
- moru/api/client/api/templates/patch_templates_template_id.py +4 -0
- moru/api/client/api/templates/post_templates.py +4 -0
- moru/api/client/api/templates/post_templates_template_id.py +3 -0
- moru/api/client/api/templates/post_templates_template_id_builds_build_id.py +3 -0
- moru/api/client/api/templates/post_v2_templates.py +4 -0
- moru/api/client/api/templates/post_v3_templates.py +4 -0
- moru/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py +3 -0
- moru/api/client/models/__init__.py +30 -0
- moru/api/client/models/admin_sandbox_kill_result.py +67 -0
- moru/api/client/models/build_log_entry.py +1 -1
- moru/api/client/models/create_volume_request.py +59 -0
- moru/api/client/models/file_info.py +105 -0
- moru/api/client/models/file_info_type.py +9 -0
- moru/api/client/models/file_list_response.py +84 -0
- moru/api/client/models/logs_direction.py +9 -0
- moru/api/client/models/logs_source.py +9 -0
- moru/api/client/models/machine_info.py +83 -0
- moru/api/client/models/new_sandbox.py +19 -0
- moru/api/client/models/node.py +10 -0
- moru/api/client/models/node_detail.py +10 -0
- moru/api/client/models/sandbox_log_entry.py +9 -9
- moru/api/client/models/sandbox_log_event_type.py +11 -0
- moru/api/client/models/sandbox_run.py +130 -0
- moru/api/client/models/sandbox_run_end_reason.py +11 -0
- moru/api/client/models/sandbox_run_status.py +10 -0
- moru/api/client/models/template_build_logs_response.py +73 -0
- moru/api/client/models/upload_response.py +67 -0
- moru/api/client/models/volume.py +105 -0
- moru/sandbox/mcp.py +835 -6
- moru/sandbox_async/commands/command.py +5 -1
- moru/sandbox_async/filesystem/filesystem.py +5 -1
- moru/sandbox_async/main.py +21 -0
- moru/sandbox_async/sandbox_api.py +17 -11
- moru/sandbox_sync/filesystem/filesystem.py +5 -1
- moru/sandbox_sync/main.py +21 -0
- moru/sandbox_sync/sandbox_api.py +17 -11
- moru/volume/__init__.py +11 -0
- moru/volume/types.py +83 -0
- moru/volume/volume_api.py +330 -0
- moru/volume_async/__init__.py +5 -0
- moru/volume_async/main.py +327 -0
- moru/volume_async/volume_api.py +290 -0
- moru/volume_sync/__init__.py +5 -0
- moru/volume_sync/main.py +325 -0
- moru-0.2.0.dist-info/METADATA +122 -0
- {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/RECORD +71 -46
- {moru-0.1.0.dist-info → moru-0.2.0.dist-info}/WHEEL +1 -1
- moru-0.1.0.dist-info/METADATA +0 -63
- {moru-0.1.0.dist-info/licenses → moru-0.2.0.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Asynchronous Volume class for persistent storage operations."""
|
|
2
|
+
|
|
3
|
+
from typing import BinaryIO, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from typing_extensions import Self, Unpack
|
|
6
|
+
|
|
7
|
+
from moru.connection_config import ApiParams, ConnectionConfig
|
|
8
|
+
from moru.volume.types import FileInfo, VolumeInfo
|
|
9
|
+
from moru.volume_async.volume_api import AsyncVolumeApi
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncVolume:
|
|
13
|
+
"""
|
|
14
|
+
Moru async volume is persistent storage for sandboxes.
|
|
15
|
+
|
|
16
|
+
Volumes provide crash-durable file storage that persists across sandbox
|
|
17
|
+
lifecycle. Data is accessible even when no sandbox is running.
|
|
18
|
+
|
|
19
|
+
Use `await AsyncVolume.create()` to create a new volume (idempotent).
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
```python
|
|
23
|
+
from moru import AsyncVolume, AsyncSandbox
|
|
24
|
+
|
|
25
|
+
# Create a volume (idempotent - returns existing if name matches)
|
|
26
|
+
vol = await AsyncVolume.create(name="my-workspace")
|
|
27
|
+
|
|
28
|
+
# Attach to sandbox
|
|
29
|
+
sbx = await AsyncSandbox.create(
|
|
30
|
+
template="base",
|
|
31
|
+
volume_id=vol.volume_id,
|
|
32
|
+
volume_mount_path="/workspace",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Work with files
|
|
36
|
+
files = await vol.list_files("/")
|
|
37
|
+
await vol.upload("/data/input.csv", b"col1,col2\\n1,2\\n")
|
|
38
|
+
content = await vol.download("/output/result.csv")
|
|
39
|
+
|
|
40
|
+
# Delete volume
|
|
41
|
+
await vol.delete()
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
volume_id: str,
|
|
48
|
+
name: str,
|
|
49
|
+
total_size_bytes: int = 0,
|
|
50
|
+
total_file_count: int = 0,
|
|
51
|
+
connection_config: Optional[ConnectionConfig] = None,
|
|
52
|
+
**opts: Unpack[ApiParams],
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize an AsyncVolume instance.
|
|
56
|
+
|
|
57
|
+
:param volume_id: Unique volume identifier
|
|
58
|
+
:param name: Volume name
|
|
59
|
+
:param total_size_bytes: Total size of files in volume
|
|
60
|
+
:param total_file_count: Total number of files in volume
|
|
61
|
+
:param connection_config: Connection configuration
|
|
62
|
+
"""
|
|
63
|
+
self._volume_id = volume_id
|
|
64
|
+
self._name = name
|
|
65
|
+
self._total_size_bytes = total_size_bytes
|
|
66
|
+
self._total_file_count = total_file_count
|
|
67
|
+
self._connection_config = connection_config or ConnectionConfig(**opts)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def volume_id(self) -> str:
|
|
71
|
+
"""Unique volume identifier."""
|
|
72
|
+
return self._volume_id
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def name(self) -> str:
|
|
76
|
+
"""Volume name."""
|
|
77
|
+
return self._name
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def total_size_bytes(self) -> int:
|
|
81
|
+
"""Total size of files in volume (bytes)."""
|
|
82
|
+
return self._total_size_bytes
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def total_file_count(self) -> int:
|
|
86
|
+
"""Total number of files in volume."""
|
|
87
|
+
return self._total_file_count
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
async def create(
|
|
91
|
+
cls,
|
|
92
|
+
name: str,
|
|
93
|
+
**opts: Unpack[ApiParams],
|
|
94
|
+
) -> Self:
|
|
95
|
+
"""
|
|
96
|
+
Create a new volume (idempotent).
|
|
97
|
+
|
|
98
|
+
If a volume with the same name already exists for this team,
|
|
99
|
+
the existing volume is returned.
|
|
100
|
+
|
|
101
|
+
:param name: Volume name (unique per team, slug format: lowercase, hyphens)
|
|
102
|
+
|
|
103
|
+
:return: AsyncVolume instance
|
|
104
|
+
|
|
105
|
+
Example:
|
|
106
|
+
```python
|
|
107
|
+
vol = await AsyncVolume.create(name="my-workspace")
|
|
108
|
+
print(f"Volume: {vol.volume_id}")
|
|
109
|
+
```
|
|
110
|
+
"""
|
|
111
|
+
info = await AsyncVolumeApi._create_volume(name=name, **opts)
|
|
112
|
+
|
|
113
|
+
return cls(
|
|
114
|
+
volume_id=info.volume_id,
|
|
115
|
+
name=info.name,
|
|
116
|
+
total_size_bytes=info.total_size_bytes,
|
|
117
|
+
total_file_count=info.total_file_count,
|
|
118
|
+
**opts,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
async def get(
|
|
123
|
+
cls,
|
|
124
|
+
volume_id_or_name: str,
|
|
125
|
+
**opts: Unpack[ApiParams],
|
|
126
|
+
) -> Self:
|
|
127
|
+
"""
|
|
128
|
+
Get a volume by ID or name.
|
|
129
|
+
|
|
130
|
+
:param volume_id_or_name: Volume ID (vol_xxx) or name
|
|
131
|
+
|
|
132
|
+
:return: AsyncVolume instance
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
```python
|
|
136
|
+
vol = await AsyncVolume.get("vol_abc123")
|
|
137
|
+
# or
|
|
138
|
+
vol = await AsyncVolume.get("my-workspace")
|
|
139
|
+
```
|
|
140
|
+
"""
|
|
141
|
+
info = await AsyncVolumeApi._get_volume(
|
|
142
|
+
volume_id_or_name=volume_id_or_name, **opts
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return cls(
|
|
146
|
+
volume_id=info.volume_id,
|
|
147
|
+
name=info.name,
|
|
148
|
+
total_size_bytes=info.total_size_bytes,
|
|
149
|
+
total_file_count=info.total_file_count,
|
|
150
|
+
**opts,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
async def list(**opts: Unpack[ApiParams]) -> List[VolumeInfo]:
|
|
155
|
+
"""
|
|
156
|
+
List all volumes.
|
|
157
|
+
|
|
158
|
+
:return: List of volume info objects
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
```python
|
|
162
|
+
volumes = await AsyncVolume.list()
|
|
163
|
+
for vol in volumes:
|
|
164
|
+
print(f"{vol.name}: {vol.total_size_bytes} bytes")
|
|
165
|
+
```
|
|
166
|
+
"""
|
|
167
|
+
volumes, _ = await AsyncVolumeApi._list_volumes(**opts)
|
|
168
|
+
return volumes
|
|
169
|
+
|
|
170
|
+
async def delete(self, **opts: Unpack[ApiParams]) -> bool:
|
|
171
|
+
"""
|
|
172
|
+
Delete the volume.
|
|
173
|
+
|
|
174
|
+
:return: True if deleted, False if not found
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
```python
|
|
178
|
+
vol = await AsyncVolume.create(name="temp-workspace")
|
|
179
|
+
await vol.delete()
|
|
180
|
+
```
|
|
181
|
+
"""
|
|
182
|
+
return await AsyncVolumeApi._delete_volume(
|
|
183
|
+
volume_id_or_name=self._volume_id,
|
|
184
|
+
**self._connection_config.get_api_params(**opts),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def get_info(self, **opts: Unpack[ApiParams]) -> VolumeInfo:
|
|
188
|
+
"""
|
|
189
|
+
Get updated volume information.
|
|
190
|
+
|
|
191
|
+
:return: Volume info with current size and file count
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
```python
|
|
195
|
+
vol = await AsyncVolume.get("my-workspace")
|
|
196
|
+
info = await vol.get_info()
|
|
197
|
+
print(f"Size: {info.total_size_bytes} bytes, Files: {info.total_file_count}")
|
|
198
|
+
```
|
|
199
|
+
"""
|
|
200
|
+
info = await AsyncVolumeApi._get_volume(
|
|
201
|
+
volume_id_or_name=self._volume_id,
|
|
202
|
+
**self._connection_config.get_api_params(**opts),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Update cached values
|
|
206
|
+
self._total_size_bytes = info.total_size_bytes
|
|
207
|
+
self._total_file_count = info.total_file_count
|
|
208
|
+
|
|
209
|
+
return info
|
|
210
|
+
|
|
211
|
+
async def list_files(
|
|
212
|
+
self,
|
|
213
|
+
path: str = "/",
|
|
214
|
+
**opts: Unpack[ApiParams],
|
|
215
|
+
) -> List[FileInfo]:
|
|
216
|
+
"""
|
|
217
|
+
List files and directories at a path.
|
|
218
|
+
|
|
219
|
+
Works even while volume is attached to a sandbox.
|
|
220
|
+
|
|
221
|
+
:param path: Directory path to list (default: "/")
|
|
222
|
+
|
|
223
|
+
:return: List of file info objects
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
```python
|
|
227
|
+
files = await vol.list_files("/src")
|
|
228
|
+
for f in files:
|
|
229
|
+
print(f"{f.name} ({f.type})")
|
|
230
|
+
```
|
|
231
|
+
"""
|
|
232
|
+
files, _ = await AsyncVolumeApi._list_files(
|
|
233
|
+
volume_id=self._volume_id,
|
|
234
|
+
path=path,
|
|
235
|
+
**self._connection_config.get_api_params(**opts),
|
|
236
|
+
)
|
|
237
|
+
return files
|
|
238
|
+
|
|
239
|
+
async def upload(
|
|
240
|
+
self,
|
|
241
|
+
path: str,
|
|
242
|
+
content: Union[bytes, BinaryIO],
|
|
243
|
+
**opts: Unpack[ApiParams],
|
|
244
|
+
) -> None:
|
|
245
|
+
"""
|
|
246
|
+
Upload file content to the volume.
|
|
247
|
+
|
|
248
|
+
Creates parent directories as needed. Works even while volume
|
|
249
|
+
is attached to a sandbox - changes are visible immediately.
|
|
250
|
+
|
|
251
|
+
:param path: Destination path in volume
|
|
252
|
+
:param content: File content as bytes or file-like object
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
```python
|
|
256
|
+
await vol.upload("/data/input.csv", b"col1,col2\\n1,2\\n")
|
|
257
|
+
|
|
258
|
+
# Or with a file
|
|
259
|
+
with open("local_file.txt", "rb") as f:
|
|
260
|
+
await vol.upload("/remote/file.txt", f.read())
|
|
261
|
+
```
|
|
262
|
+
"""
|
|
263
|
+
if hasattr(content, "read"):
|
|
264
|
+
content = content.read()
|
|
265
|
+
|
|
266
|
+
await AsyncVolumeApi._upload_file(
|
|
267
|
+
volume_id=self._volume_id,
|
|
268
|
+
path=path,
|
|
269
|
+
content=content,
|
|
270
|
+
**self._connection_config.get_api_params(**opts),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
async def download(
|
|
274
|
+
self,
|
|
275
|
+
path: str,
|
|
276
|
+
**opts: Unpack[ApiParams],
|
|
277
|
+
) -> bytes:
|
|
278
|
+
"""
|
|
279
|
+
Download file content from the volume.
|
|
280
|
+
|
|
281
|
+
Works even while volume is attached to a sandbox.
|
|
282
|
+
|
|
283
|
+
:param path: File path in volume
|
|
284
|
+
|
|
285
|
+
:return: File content as bytes
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
```python
|
|
289
|
+
content = await vol.download("/output/result.csv")
|
|
290
|
+
print(content.decode("utf-8"))
|
|
291
|
+
```
|
|
292
|
+
"""
|
|
293
|
+
return await AsyncVolumeApi._download_file(
|
|
294
|
+
volume_id=self._volume_id,
|
|
295
|
+
path=path,
|
|
296
|
+
**self._connection_config.get_api_params(**opts),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def delete_file(
|
|
300
|
+
self,
|
|
301
|
+
path: str,
|
|
302
|
+
recursive: bool = False,
|
|
303
|
+
**opts: Unpack[ApiParams],
|
|
304
|
+
) -> bool:
|
|
305
|
+
"""
|
|
306
|
+
Delete file or directory from the volume.
|
|
307
|
+
|
|
308
|
+
:param path: Path to delete
|
|
309
|
+
:param recursive: Delete directory recursively
|
|
310
|
+
|
|
311
|
+
:return: True if deleted
|
|
312
|
+
|
|
313
|
+
Example:
|
|
314
|
+
```python
|
|
315
|
+
await vol.delete_file("/temp/cache.txt")
|
|
316
|
+
await vol.delete_file("/temp/", recursive=True)
|
|
317
|
+
```
|
|
318
|
+
"""
|
|
319
|
+
return await AsyncVolumeApi._delete_file(
|
|
320
|
+
volume_id=self._volume_id,
|
|
321
|
+
path=path,
|
|
322
|
+
recursive=recursive,
|
|
323
|
+
**self._connection_config.get_api_params(**opts),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def __repr__(self) -> str:
|
|
327
|
+
return f"AsyncVolume(volume_id={self._volume_id!r}, name={self._name!r})"
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Async 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 handle_api_exception
|
|
8
|
+
from moru.api.client_async import get_api_client
|
|
9
|
+
from moru.connection_config import ApiParams, ConnectionConfig
|
|
10
|
+
from moru.exceptions import NotFoundException
|
|
11
|
+
from moru.volume.types import FileInfo, VolumeInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncVolumeApi:
|
|
15
|
+
"""Base class for async volume API operations."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
async def _create_volume(
|
|
19
|
+
name: str,
|
|
20
|
+
**opts: Unpack[ApiParams],
|
|
21
|
+
) -> VolumeInfo:
|
|
22
|
+
"""
|
|
23
|
+
Create a new volume (idempotent).
|
|
24
|
+
|
|
25
|
+
:param name: Volume name
|
|
26
|
+
:return: Volume info
|
|
27
|
+
"""
|
|
28
|
+
config = ConnectionConfig(**opts)
|
|
29
|
+
api_client = get_api_client(config)
|
|
30
|
+
client = api_client.get_async_httpx_client()
|
|
31
|
+
|
|
32
|
+
response = await client.post(
|
|
33
|
+
f"{config.api_url}/volumes",
|
|
34
|
+
json={"name": name},
|
|
35
|
+
timeout=config.request_timeout,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
err = handle_api_exception(response)
|
|
39
|
+
if err:
|
|
40
|
+
raise err
|
|
41
|
+
|
|
42
|
+
return VolumeInfo._from_api_response(response.json())
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
async def _get_volume(
|
|
46
|
+
volume_id_or_name: str,
|
|
47
|
+
**opts: Unpack[ApiParams],
|
|
48
|
+
) -> VolumeInfo:
|
|
49
|
+
"""
|
|
50
|
+
Get volume by ID or name.
|
|
51
|
+
|
|
52
|
+
:param volume_id_or_name: Volume ID (vol_xxx) or name
|
|
53
|
+
:return: Volume info
|
|
54
|
+
"""
|
|
55
|
+
config = ConnectionConfig(**opts)
|
|
56
|
+
api_client = get_api_client(config)
|
|
57
|
+
client = api_client.get_async_httpx_client()
|
|
58
|
+
|
|
59
|
+
response = await client.get(
|
|
60
|
+
f"{config.api_url}/volumes/{volume_id_or_name}",
|
|
61
|
+
timeout=config.request_timeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if response.status_code == 404:
|
|
65
|
+
raise NotFoundException(f"Volume '{volume_id_or_name}' not found")
|
|
66
|
+
|
|
67
|
+
err = handle_api_exception(response)
|
|
68
|
+
if err:
|
|
69
|
+
raise err
|
|
70
|
+
|
|
71
|
+
return VolumeInfo._from_api_response(response.json())
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
async def _list_volumes(
|
|
75
|
+
limit: Optional[int] = None,
|
|
76
|
+
next_token: Optional[str] = None,
|
|
77
|
+
**opts: Unpack[ApiParams],
|
|
78
|
+
) -> Tuple[List[VolumeInfo], Optional[str]]:
|
|
79
|
+
"""
|
|
80
|
+
List all volumes.
|
|
81
|
+
|
|
82
|
+
:param limit: Maximum number of volumes to return
|
|
83
|
+
:param next_token: Pagination token
|
|
84
|
+
:return: Tuple of (volumes, next_token)
|
|
85
|
+
"""
|
|
86
|
+
config = ConnectionConfig(**opts)
|
|
87
|
+
api_client = get_api_client(config)
|
|
88
|
+
client = api_client.get_async_httpx_client()
|
|
89
|
+
|
|
90
|
+
params = {}
|
|
91
|
+
if limit is not None:
|
|
92
|
+
params["limit"] = limit
|
|
93
|
+
if next_token is not None:
|
|
94
|
+
params["nextToken"] = next_token
|
|
95
|
+
|
|
96
|
+
response = await client.get(
|
|
97
|
+
f"{config.api_url}/volumes",
|
|
98
|
+
params=params,
|
|
99
|
+
timeout=config.request_timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
err = handle_api_exception(response)
|
|
103
|
+
if err:
|
|
104
|
+
raise err
|
|
105
|
+
|
|
106
|
+
volumes = [VolumeInfo._from_api_response(v) for v in response.json()]
|
|
107
|
+
result_next_token = response.headers.get("x-next-token")
|
|
108
|
+
|
|
109
|
+
return volumes, result_next_token
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
async def _delete_volume(
|
|
113
|
+
volume_id_or_name: str,
|
|
114
|
+
**opts: Unpack[ApiParams],
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""
|
|
117
|
+
Delete a volume.
|
|
118
|
+
|
|
119
|
+
:param volume_id_or_name: Volume ID (vol_xxx) or name
|
|
120
|
+
:return: True if deleted, False if not found
|
|
121
|
+
"""
|
|
122
|
+
config = ConnectionConfig(**opts)
|
|
123
|
+
api_client = get_api_client(config)
|
|
124
|
+
client = api_client.get_async_httpx_client()
|
|
125
|
+
|
|
126
|
+
response = await client.delete(
|
|
127
|
+
f"{config.api_url}/volumes/{volume_id_or_name}",
|
|
128
|
+
timeout=config.request_timeout,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if response.status_code == 404:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
err = handle_api_exception(response)
|
|
135
|
+
if err:
|
|
136
|
+
raise err
|
|
137
|
+
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
async def _list_files(
|
|
142
|
+
volume_id: str,
|
|
143
|
+
path: str = "/",
|
|
144
|
+
limit: Optional[int] = None,
|
|
145
|
+
next_token: Optional[str] = None,
|
|
146
|
+
**opts: Unpack[ApiParams],
|
|
147
|
+
) -> Tuple[List[FileInfo], Optional[str]]:
|
|
148
|
+
"""
|
|
149
|
+
List files in a volume.
|
|
150
|
+
|
|
151
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
152
|
+
:param path: Directory path to list
|
|
153
|
+
:param limit: Maximum number of files to return
|
|
154
|
+
:param next_token: Pagination token
|
|
155
|
+
:return: Tuple of (files, next_token)
|
|
156
|
+
"""
|
|
157
|
+
config = ConnectionConfig(**opts)
|
|
158
|
+
api_client = get_api_client(config)
|
|
159
|
+
client = api_client.get_async_httpx_client()
|
|
160
|
+
|
|
161
|
+
params = {"path": path}
|
|
162
|
+
if limit is not None:
|
|
163
|
+
params["limit"] = limit
|
|
164
|
+
if next_token is not None:
|
|
165
|
+
params["nextToken"] = next_token
|
|
166
|
+
|
|
167
|
+
response = await client.get(
|
|
168
|
+
f"{config.api_url}/volumes/{volume_id}/files",
|
|
169
|
+
params=params,
|
|
170
|
+
timeout=config.request_timeout,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if response.status_code == 404:
|
|
174
|
+
raise NotFoundException(f"Volume '{volume_id}' not found")
|
|
175
|
+
|
|
176
|
+
err = handle_api_exception(response)
|
|
177
|
+
if err:
|
|
178
|
+
raise err
|
|
179
|
+
|
|
180
|
+
data = response.json()
|
|
181
|
+
files = [FileInfo._from_api_response(f) for f in data.get("files", [])]
|
|
182
|
+
result_next_token = data.get("nextToken")
|
|
183
|
+
|
|
184
|
+
return files, result_next_token
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
async def _upload_file(
|
|
188
|
+
volume_id: str,
|
|
189
|
+
path: str,
|
|
190
|
+
content: bytes,
|
|
191
|
+
**opts: Unpack[ApiParams],
|
|
192
|
+
) -> int:
|
|
193
|
+
"""
|
|
194
|
+
Upload file content to volume.
|
|
195
|
+
|
|
196
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
197
|
+
:param path: Destination path in volume
|
|
198
|
+
:param content: File content as bytes
|
|
199
|
+
:return: Size of uploaded file
|
|
200
|
+
"""
|
|
201
|
+
config = ConnectionConfig(**opts)
|
|
202
|
+
api_client = get_api_client(config)
|
|
203
|
+
client = api_client.get_async_httpx_client()
|
|
204
|
+
|
|
205
|
+
response = await client.put(
|
|
206
|
+
f"{config.api_url}/volumes/{volume_id}/files/upload",
|
|
207
|
+
params={"path": path},
|
|
208
|
+
content=content,
|
|
209
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
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
|
+
return response.json().get("size", len(content))
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
async def _download_file(
|
|
224
|
+
volume_id: str,
|
|
225
|
+
path: str,
|
|
226
|
+
**opts: Unpack[ApiParams],
|
|
227
|
+
) -> bytes:
|
|
228
|
+
"""
|
|
229
|
+
Download file content from volume.
|
|
230
|
+
|
|
231
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
232
|
+
:param path: File path in volume
|
|
233
|
+
:return: File content as bytes
|
|
234
|
+
"""
|
|
235
|
+
config = ConnectionConfig(**opts)
|
|
236
|
+
api_client = get_api_client(config)
|
|
237
|
+
client = api_client.get_async_httpx_client()
|
|
238
|
+
|
|
239
|
+
response = await client.get(
|
|
240
|
+
f"{config.api_url}/volumes/{volume_id}/files/download",
|
|
241
|
+
params={"path": path},
|
|
242
|
+
timeout=config.request_timeout,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if response.status_code == 404:
|
|
246
|
+
raise NotFoundException(f"Volume '{volume_id}' or file '{path}' not found")
|
|
247
|
+
|
|
248
|
+
err = handle_api_exception(response)
|
|
249
|
+
if err:
|
|
250
|
+
raise err
|
|
251
|
+
|
|
252
|
+
return response.content
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
async def _delete_file(
|
|
256
|
+
volume_id: str,
|
|
257
|
+
path: str,
|
|
258
|
+
recursive: bool = False,
|
|
259
|
+
**opts: Unpack[ApiParams],
|
|
260
|
+
) -> bool:
|
|
261
|
+
"""
|
|
262
|
+
Delete file or directory from volume.
|
|
263
|
+
|
|
264
|
+
:param volume_id: Volume ID (vol_xxx)
|
|
265
|
+
:param path: Path to delete
|
|
266
|
+
:param recursive: Delete directory recursively
|
|
267
|
+
:return: True if deleted
|
|
268
|
+
"""
|
|
269
|
+
config = ConnectionConfig(**opts)
|
|
270
|
+
api_client = get_api_client(config)
|
|
271
|
+
client = api_client.get_async_httpx_client()
|
|
272
|
+
|
|
273
|
+
params: dict = {"path": path}
|
|
274
|
+
if recursive:
|
|
275
|
+
params["recursive"] = "true"
|
|
276
|
+
|
|
277
|
+
response = await client.delete(
|
|
278
|
+
f"{config.api_url}/volumes/{volume_id}/files",
|
|
279
|
+
params=params,
|
|
280
|
+
timeout=config.request_timeout,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if response.status_code == 404:
|
|
284
|
+
raise NotFoundException(f"Volume '{volume_id}' or path '{path}' not found")
|
|
285
|
+
|
|
286
|
+
err = handle_api_exception(response)
|
|
287
|
+
if err:
|
|
288
|
+
raise err
|
|
289
|
+
|
|
290
|
+
return True
|