modal 1.0.6.dev58__py3-none-any.whl → 1.2.3.dev7__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +4 -2
- modal/_container_entrypoint.py +41 -49
- modal/_functions.py +424 -195
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +68 -20
- modal/_output.py +58 -45
- modal/_partial_function.py +36 -11
- modal/_pty.py +7 -3
- modal/_resolver.py +21 -35
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +301 -186
- modal/_runtime/container_io_manager.pyi +70 -61
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +4 -1
- modal/_runtime/gpu_memory_snapshot.py +170 -63
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +57 -1
- modal/_utils/async_utils.py +33 -12
- modal/_utils/auth_token_manager.py +2 -5
- modal/_utils/blob_utils.py +110 -53
- modal/_utils/function_utils.py +49 -42
- modal/_utils/grpc_utils.py +80 -50
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +219 -83
- modal/app.pyi +229 -56
- modal/billing.py +5 -0
- modal/{requirements → builder}/2025.06.txt +1 -0
- modal/{requirements → builder}/PREVIEW.txt +1 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +9 -13
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +58 -16
- modal/cli/secret.py +48 -22
- modal/cli/utils.py +3 -4
- modal/cli/volume.py +28 -25
- modal/client.py +13 -116
- modal/client.pyi +9 -91
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +5 -1
- modal/cls.py +130 -102
- modal/cls.pyi +45 -85
- modal/config.py +29 -10
- modal/container_process.py +291 -13
- modal/container_process.pyi +95 -32
- modal/dict.py +282 -63
- modal/dict.pyi +423 -73
- modal/environments.py +15 -27
- modal/environments.pyi +5 -15
- modal/exception.py +8 -0
- modal/experimental/__init__.py +143 -38
- modal/experimental/flash.py +247 -78
- modal/experimental/flash.pyi +137 -9
- modal/file_io.py +14 -28
- modal/file_io.pyi +2 -2
- modal/file_pattern_matcher.py +25 -16
- modal/functions.pyi +134 -61
- modal/image.py +255 -86
- modal/image.pyi +300 -62
- modal/io_streams.py +436 -126
- modal/io_streams.pyi +236 -171
- modal/mount.py +62 -157
- modal/mount.pyi +45 -172
- modal/network_file_system.py +30 -53
- modal/network_file_system.pyi +16 -76
- modal/object.pyi +42 -8
- modal/parallel_map.py +821 -113
- modal/parallel_map.pyi +134 -0
- modal/partial_function.pyi +4 -1
- modal/proxy.py +16 -7
- modal/proxy.pyi +10 -2
- modal/queue.py +263 -61
- modal/queue.pyi +409 -66
- modal/runner.py +112 -92
- modal/runner.pyi +45 -27
- modal/sandbox.py +451 -124
- modal/sandbox.pyi +513 -67
- modal/secret.py +291 -67
- modal/secret.pyi +425 -19
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/token_flow.py +4 -4
- modal/volume.py +344 -98
- modal/volume.pyi +464 -68
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +9 -8
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +11 -1
- modal_proto/api.proto +399 -67
- modal_proto/api_grpc.py +241 -1
- modal_proto/api_pb2.py +1395 -1000
- modal_proto/api_pb2.pyi +1239 -79
- modal_proto/api_pb2_grpc.py +499 -4
- modal_proto/api_pb2_grpc.pyi +162 -14
- modal_proto/modal_api_grpc.py +175 -160
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal-1.0.6.dev58.dist-info/RECORD +0 -183
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- /modal/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- /modal/{requirements → builder}/base-images.json +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.6.dev58.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/volume.py
CHANGED
|
@@ -11,6 +11,7 @@ import time
|
|
|
11
11
|
import typing
|
|
12
12
|
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Sequence
|
|
13
13
|
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
14
15
|
from io import BytesIO
|
|
15
16
|
from pathlib import Path, PurePosixPath
|
|
16
17
|
from typing import (
|
|
@@ -24,14 +25,22 @@ from typing import (
|
|
|
24
25
|
|
|
25
26
|
from google.protobuf.message import Message
|
|
26
27
|
from grpclib import GRPCError, Status
|
|
28
|
+
from synchronicity import classproperty
|
|
27
29
|
from synchronicity.async_wrap import asynccontextmanager
|
|
28
30
|
|
|
29
31
|
import modal.exception
|
|
30
32
|
import modal_proto.api_pb2
|
|
31
|
-
from modal.exception import InvalidError, VolumeUploadTimeoutError
|
|
33
|
+
from modal.exception import AlreadyExistsError, InvalidError, NotFoundError, VolumeUploadTimeoutError
|
|
32
34
|
from modal_proto import api_pb2
|
|
33
35
|
|
|
34
|
-
from .
|
|
36
|
+
from ._load_context import LoadContext
|
|
37
|
+
from ._object import (
|
|
38
|
+
EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
|
|
39
|
+
_get_environment_name,
|
|
40
|
+
_Object,
|
|
41
|
+
live_method,
|
|
42
|
+
live_method_gen,
|
|
43
|
+
)
|
|
35
44
|
from ._resolver import Resolver
|
|
36
45
|
from ._utils.async_utils import (
|
|
37
46
|
TaskContext,
|
|
@@ -51,9 +60,10 @@ from ._utils.blob_utils import (
|
|
|
51
60
|
get_file_upload_spec_from_path,
|
|
52
61
|
)
|
|
53
62
|
from ._utils.deprecation import deprecation_warning, warn_if_passing_namespace
|
|
54
|
-
from ._utils.grpc_utils import
|
|
63
|
+
from ._utils.grpc_utils import Retry
|
|
55
64
|
from ._utils.http_utils import ClientSessionRegistry
|
|
56
65
|
from ._utils.name_utils import check_object_name
|
|
66
|
+
from ._utils.time_utils import as_timestamp, timestamp_to_localized_dt
|
|
57
67
|
from .client import _Client
|
|
58
68
|
from .config import logger
|
|
59
69
|
|
|
@@ -92,6 +102,191 @@ class FileEntry:
|
|
|
92
102
|
)
|
|
93
103
|
|
|
94
104
|
|
|
105
|
+
@dataclass
|
|
106
|
+
class VolumeInfo:
|
|
107
|
+
"""Information about the Volume object."""
|
|
108
|
+
|
|
109
|
+
# This dataclass should be limited to information that is unchanging over the lifetime of the Volume,
|
|
110
|
+
# since it is transmitted from the server when the object is hydrated and could be stale when accessed.
|
|
111
|
+
|
|
112
|
+
name: Optional[str]
|
|
113
|
+
created_at: datetime
|
|
114
|
+
created_by: Optional[str]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _VolumeManager:
|
|
118
|
+
"""Namespace with methods for managing named Volume objects."""
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
async def create(
|
|
122
|
+
name: str, # Name to use for the new Volume
|
|
123
|
+
*,
|
|
124
|
+
version: Optional[int] = None, # Experimental: Configure the backend VolumeFS version
|
|
125
|
+
allow_existing: bool = False, # If True, no-op when the Volume already exists
|
|
126
|
+
environment_name: Optional[str] = None, # Uses active environment if not specified
|
|
127
|
+
client: Optional[_Client] = None, # Optional client with Modal credentials
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Create a new Volume object.
|
|
130
|
+
|
|
131
|
+
**Examples:**
|
|
132
|
+
|
|
133
|
+
```python notest
|
|
134
|
+
modal.Volume.objects.create("my-volume")
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Volumes will be created in the active environment, or another one can be specified:
|
|
138
|
+
|
|
139
|
+
```python notest
|
|
140
|
+
modal.Volume.objects.create("my-volume", environment_name="dev")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
By default, an error will be raised if the Volume already exists, but passing
|
|
144
|
+
`allow_existing=True` will make the creation attempt a no-op in this case.
|
|
145
|
+
|
|
146
|
+
```python notest
|
|
147
|
+
modal.Volume.objects.create("my-volume", allow_existing=True)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Note that this method does not return a local instance of the Volume. You can use
|
|
151
|
+
`modal.Volume.from_name` to perform a lookup after creation.
|
|
152
|
+
|
|
153
|
+
Added in v1.1.2.
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
check_object_name(name, "Volume")
|
|
157
|
+
client = await _Client.from_env() if client is None else client
|
|
158
|
+
object_creation_type = (
|
|
159
|
+
api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING
|
|
160
|
+
if allow_existing
|
|
161
|
+
else api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if version is not None and version not in {1, 2}:
|
|
165
|
+
raise InvalidError("VolumeFS version must be either 1 or 2")
|
|
166
|
+
|
|
167
|
+
req = api_pb2.VolumeGetOrCreateRequest(
|
|
168
|
+
deployment_name=name,
|
|
169
|
+
environment_name=_get_environment_name(environment_name),
|
|
170
|
+
object_creation_type=object_creation_type,
|
|
171
|
+
version=version,
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
await client.stub.VolumeGetOrCreate(req)
|
|
175
|
+
except GRPCError as exc:
|
|
176
|
+
if exc.status == Status.ALREADY_EXISTS and not allow_existing:
|
|
177
|
+
raise AlreadyExistsError(exc.message)
|
|
178
|
+
else:
|
|
179
|
+
raise
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
async def list(
|
|
183
|
+
*,
|
|
184
|
+
max_objects: Optional[int] = None, # Limit requests to this size
|
|
185
|
+
created_before: Optional[Union[datetime, str]] = None, # Limit based on creation date
|
|
186
|
+
environment_name: str = "", # Uses active environment if not specified
|
|
187
|
+
client: Optional[_Client] = None, # Optional client with Modal credentials
|
|
188
|
+
) -> list["_Volume"]:
|
|
189
|
+
"""Return a list of hydrated Volume objects.
|
|
190
|
+
|
|
191
|
+
**Examples:**
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
volumes = modal.Volume.objects.list()
|
|
195
|
+
print([v.name for v in volumes])
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Volumes will be retreived from the active environment, or another one can be specified:
|
|
199
|
+
|
|
200
|
+
```python notest
|
|
201
|
+
dev_volumes = modal.Volume.objects.list(environment_name="dev")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
By default, all named Volumes are returned, newest to oldest. It's also possible to limit the
|
|
205
|
+
number of results and to filter by creation date:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
volumes = modal.Volume.objects.list(max_objects=10, created_before="2025-01-01")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Added in v1.1.2.
|
|
212
|
+
|
|
213
|
+
"""
|
|
214
|
+
client = await _Client.from_env() if client is None else client
|
|
215
|
+
if max_objects is not None and max_objects < 0:
|
|
216
|
+
raise InvalidError("max_objects cannot be negative")
|
|
217
|
+
|
|
218
|
+
items: list[api_pb2.VolumeListItem] = []
|
|
219
|
+
|
|
220
|
+
async def retrieve_page(created_before: float) -> bool:
|
|
221
|
+
max_page_size = 100 if max_objects is None else min(100, max_objects - len(items))
|
|
222
|
+
pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
|
|
223
|
+
req = api_pb2.VolumeListRequest(
|
|
224
|
+
environment_name=_get_environment_name(environment_name), pagination=pagination
|
|
225
|
+
)
|
|
226
|
+
resp = await client.stub.VolumeList(req)
|
|
227
|
+
items.extend(resp.items)
|
|
228
|
+
finished = (len(resp.items) < max_page_size) or (max_objects is not None and len(items) >= max_objects)
|
|
229
|
+
return finished
|
|
230
|
+
|
|
231
|
+
finished = await retrieve_page(as_timestamp(created_before))
|
|
232
|
+
while True:
|
|
233
|
+
if finished:
|
|
234
|
+
break
|
|
235
|
+
finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
|
|
236
|
+
|
|
237
|
+
volumes = [
|
|
238
|
+
_Volume._new_hydrated(
|
|
239
|
+
item.volume_id,
|
|
240
|
+
client,
|
|
241
|
+
item.metadata,
|
|
242
|
+
is_another_app=True,
|
|
243
|
+
rep=_Volume._repr(item.label, environment_name),
|
|
244
|
+
)
|
|
245
|
+
for item in items
|
|
246
|
+
]
|
|
247
|
+
return volumes[:max_objects] if max_objects is not None else volumes
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
async def delete(
|
|
251
|
+
name: str, # Name of the Volume to delete
|
|
252
|
+
*,
|
|
253
|
+
allow_missing: bool = False, # If True, don't raise an error if the Volume doesn't exist
|
|
254
|
+
environment_name: Optional[str] = None, # Uses active environment if not specified
|
|
255
|
+
client: Optional[_Client] = None, # Optional client with Modal credentials
|
|
256
|
+
):
|
|
257
|
+
"""Delete a named Volume.
|
|
258
|
+
|
|
259
|
+
Warning: This deletes an *entire Volume*, not just a specific file.
|
|
260
|
+
Deletion is irreversible and will affect any Apps currently using the Volume.
|
|
261
|
+
|
|
262
|
+
**Examples:**
|
|
263
|
+
|
|
264
|
+
```python notest
|
|
265
|
+
await modal.Volume.objects.delete("my-volume")
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Volumes will be deleted from the active environment, or another one can be specified:
|
|
269
|
+
|
|
270
|
+
```python notest
|
|
271
|
+
await modal.Volume.objects.delete("my-volume", environment_name="dev")
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Added in v1.1.2.
|
|
275
|
+
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
obj = await _Volume.from_name(name, environment_name=environment_name).hydrate(client)
|
|
279
|
+
except NotFoundError:
|
|
280
|
+
if not allow_missing:
|
|
281
|
+
raise
|
|
282
|
+
else:
|
|
283
|
+
req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
|
|
284
|
+
await obj._client.stub.VolumeDelete(req)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
VolumeManager = synchronize_api(_VolumeManager)
|
|
288
|
+
|
|
289
|
+
|
|
95
290
|
class _Volume(_Object, type_prefix="vo"):
|
|
96
291
|
"""A writeable volume that can be used to share files between one or more Modal functions.
|
|
97
292
|
|
|
@@ -138,6 +333,14 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
138
333
|
_metadata: "typing.Optional[api_pb2.VolumeMetadata]"
|
|
139
334
|
_read_only: bool = False
|
|
140
335
|
|
|
336
|
+
@classproperty
|
|
337
|
+
def objects(cls) -> _VolumeManager:
|
|
338
|
+
return _VolumeManager
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def name(self) -> Optional[str]:
|
|
342
|
+
return self._name
|
|
343
|
+
|
|
141
344
|
def read_only(self) -> "_Volume":
|
|
142
345
|
"""Configure Volume to mount as read-only.
|
|
143
346
|
|
|
@@ -156,15 +359,34 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
156
359
|
|
|
157
360
|
The Volume is mounted as a read-only volume in a function. Any file system write operation into the
|
|
158
361
|
mounted volume will result in an error.
|
|
362
|
+
|
|
363
|
+
Added in v1.0.5.
|
|
159
364
|
"""
|
|
160
365
|
|
|
161
|
-
async def _load(
|
|
366
|
+
async def _load(
|
|
367
|
+
new_volume: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
|
|
368
|
+
):
|
|
162
369
|
new_volume._initialize_from_other(self)
|
|
163
370
|
new_volume._read_only = True
|
|
164
371
|
|
|
165
|
-
obj = _Volume._from_loader(
|
|
372
|
+
obj = _Volume._from_loader(
|
|
373
|
+
_load,
|
|
374
|
+
"Volume()",
|
|
375
|
+
hydrate_lazily=True,
|
|
376
|
+
deps=lambda: [self],
|
|
377
|
+
load_context_overrides=self._load_context_overrides,
|
|
378
|
+
)
|
|
166
379
|
return obj
|
|
167
380
|
|
|
381
|
+
def _hydrate_metadata(self, metadata: Optional[Message]):
|
|
382
|
+
if metadata:
|
|
383
|
+
assert isinstance(metadata, api_pb2.VolumeMetadata)
|
|
384
|
+
self._metadata = metadata
|
|
385
|
+
self._name = metadata.name
|
|
386
|
+
|
|
387
|
+
def _get_metadata(self) -> Optional[Message]:
|
|
388
|
+
return self._metadata
|
|
389
|
+
|
|
168
390
|
async def _get_lock(self):
|
|
169
391
|
# To (mostly*) prevent multiple concurrent operations on the same volume, which can cause problems under
|
|
170
392
|
# some unlikely circumstances.
|
|
@@ -179,6 +401,14 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
179
401
|
self._lock = asyncio.Lock()
|
|
180
402
|
return self._lock
|
|
181
403
|
|
|
404
|
+
@property
|
|
405
|
+
def _is_v1(self) -> bool:
|
|
406
|
+
return self._metadata.version in [
|
|
407
|
+
None,
|
|
408
|
+
api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_UNSPECIFIED,
|
|
409
|
+
api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_V1,
|
|
410
|
+
]
|
|
411
|
+
|
|
182
412
|
@staticmethod
|
|
183
413
|
def from_name(
|
|
184
414
|
name: str,
|
|
@@ -187,6 +417,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
187
417
|
environment_name: Optional[str] = None,
|
|
188
418
|
create_if_missing: bool = False,
|
|
189
419
|
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
|
420
|
+
client: Optional[_Client] = None,
|
|
190
421
|
) -> "_Volume":
|
|
191
422
|
"""Reference a Volume by name, creating if necessary.
|
|
192
423
|
|
|
@@ -208,34 +439,26 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
208
439
|
check_object_name(name, "Volume")
|
|
209
440
|
warn_if_passing_namespace(namespace, "modal.Volume.from_name")
|
|
210
441
|
|
|
211
|
-
async def _load(
|
|
442
|
+
async def _load(
|
|
443
|
+
self: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
|
|
444
|
+
):
|
|
212
445
|
req = api_pb2.VolumeGetOrCreateRequest(
|
|
213
446
|
deployment_name=name,
|
|
214
|
-
environment_name=
|
|
447
|
+
environment_name=load_context.environment_name,
|
|
215
448
|
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
|
216
449
|
version=version,
|
|
217
450
|
)
|
|
218
|
-
response = await
|
|
219
|
-
self._hydrate(response.volume_id,
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _get_metadata(self) -> Optional[Message]:
|
|
230
|
-
return self._metadata
|
|
231
|
-
|
|
232
|
-
@property
|
|
233
|
-
def _is_v1(self) -> bool:
|
|
234
|
-
return self._metadata.version in [
|
|
235
|
-
None,
|
|
236
|
-
api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_UNSPECIFIED,
|
|
237
|
-
api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_V1,
|
|
238
|
-
]
|
|
451
|
+
response = await load_context.client.stub.VolumeGetOrCreate(req)
|
|
452
|
+
self._hydrate(response.volume_id, load_context.client, response.metadata)
|
|
453
|
+
|
|
454
|
+
rep = _Volume._repr(name, environment_name)
|
|
455
|
+
return _Volume._from_loader(
|
|
456
|
+
_load,
|
|
457
|
+
rep,
|
|
458
|
+
hydrate_lazily=True,
|
|
459
|
+
name=name,
|
|
460
|
+
load_context_overrides=LoadContext(client=client, environment_name=environment_name),
|
|
461
|
+
)
|
|
239
462
|
|
|
240
463
|
@classmethod
|
|
241
464
|
@asynccontextmanager
|
|
@@ -244,7 +467,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
244
467
|
client: Optional[_Client] = None,
|
|
245
468
|
environment_name: Optional[str] = None,
|
|
246
469
|
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
|
247
|
-
_heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
|
|
470
|
+
_heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, # mdmd:line-hidden
|
|
248
471
|
) -> AsyncGenerator["_Volume", None]:
|
|
249
472
|
"""Creates a new ephemeral volume within a context manager:
|
|
250
473
|
|
|
@@ -271,51 +494,32 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
271
494
|
async with TaskContext() as tc:
|
|
272
495
|
request = api_pb2.VolumeHeartbeatRequest(volume_id=response.volume_id)
|
|
273
496
|
tc.infinite_loop(lambda: client.stub.VolumeHeartbeat(request), sleep=_heartbeat_sleep)
|
|
274
|
-
yield cls._new_hydrated(
|
|
497
|
+
yield cls._new_hydrated(
|
|
498
|
+
response.volume_id,
|
|
499
|
+
client,
|
|
500
|
+
response.metadata,
|
|
501
|
+
is_another_app=True,
|
|
502
|
+
rep="modal.Volume.ephemeral()",
|
|
503
|
+
)
|
|
275
504
|
|
|
276
505
|
@staticmethod
|
|
277
|
-
async def
|
|
278
|
-
|
|
506
|
+
async def create_deployed(
|
|
507
|
+
deployment_name: str,
|
|
279
508
|
namespace=None, # mdmd:line-hidden
|
|
280
509
|
client: Optional[_Client] = None,
|
|
281
510
|
environment_name: Optional[str] = None,
|
|
282
|
-
create_if_missing: bool = False,
|
|
283
511
|
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
|
284
|
-
) ->
|
|
285
|
-
"""mdmd:hidden
|
|
286
|
-
Lookup a named Volume.
|
|
287
|
-
|
|
288
|
-
DEPRECATED: This method is deprecated in favor of `modal.Volume.from_name`.
|
|
289
|
-
|
|
290
|
-
In contrast to `modal.Volume.from_name`, this is an eager method
|
|
291
|
-
that will hydrate the local object with metadata from Modal servers.
|
|
292
|
-
|
|
293
|
-
```python notest
|
|
294
|
-
vol = modal.Volume.from_name("my-volume")
|
|
295
|
-
print(vol.listdir("/"))
|
|
296
|
-
```
|
|
297
|
-
"""
|
|
512
|
+
) -> str:
|
|
513
|
+
"""mdmd:hidden"""
|
|
298
514
|
deprecation_warning(
|
|
299
|
-
(2025,
|
|
300
|
-
"`modal.Volume.
|
|
301
|
-
" It can be replaced with `modal.Volume.
|
|
302
|
-
"\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
|
|
303
|
-
)
|
|
304
|
-
warn_if_passing_namespace(namespace, "modal.Volume.lookup")
|
|
305
|
-
obj = _Volume.from_name(
|
|
306
|
-
name,
|
|
307
|
-
environment_name=environment_name,
|
|
308
|
-
create_if_missing=create_if_missing,
|
|
309
|
-
version=version,
|
|
515
|
+
(2025, 8, 13),
|
|
516
|
+
"The undocumented `modal.Volume.create_deployed` method is deprecated and will be removed "
|
|
517
|
+
"in a future release. It can be replaced with `modal.Volume.objects.create`.",
|
|
310
518
|
)
|
|
311
|
-
|
|
312
|
-
client = await _Client.from_env()
|
|
313
|
-
resolver = Resolver(client=client)
|
|
314
|
-
await resolver.load(obj)
|
|
315
|
-
return obj
|
|
519
|
+
return await _Volume._create_deployed(deployment_name, namespace, client, environment_name, version)
|
|
316
520
|
|
|
317
521
|
@staticmethod
|
|
318
|
-
async def
|
|
522
|
+
async def _create_deployed(
|
|
319
523
|
deployment_name: str,
|
|
320
524
|
namespace=None, # mdmd:line-hidden
|
|
321
525
|
client: Optional[_Client] = None,
|
|
@@ -333,18 +537,31 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
333
537
|
object_creation_type=api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS,
|
|
334
538
|
version=version,
|
|
335
539
|
)
|
|
336
|
-
resp = await
|
|
540
|
+
resp = await client.stub.VolumeGetOrCreate(request)
|
|
337
541
|
return resp.volume_id
|
|
338
542
|
|
|
543
|
+
@live_method
|
|
544
|
+
async def info(self) -> VolumeInfo:
|
|
545
|
+
"""Return information about the Volume object."""
|
|
546
|
+
metadata = self._get_metadata()
|
|
547
|
+
if not metadata:
|
|
548
|
+
return VolumeInfo()
|
|
549
|
+
creation_info = metadata.creation_info
|
|
550
|
+
return VolumeInfo(
|
|
551
|
+
name=metadata.name or None,
|
|
552
|
+
created_at=timestamp_to_localized_dt(creation_info.created_at),
|
|
553
|
+
created_by=creation_info.created_by or None,
|
|
554
|
+
)
|
|
555
|
+
|
|
339
556
|
@live_method
|
|
340
557
|
async def _do_reload(self, lock=True):
|
|
341
558
|
async with (await self._get_lock()) if lock else asyncnullcontext():
|
|
342
559
|
req = api_pb2.VolumeReloadRequest(volume_id=self.object_id)
|
|
343
|
-
_ = await
|
|
560
|
+
_ = await self._client.stub.VolumeReload(req)
|
|
344
561
|
|
|
345
562
|
@live_method
|
|
346
563
|
async def commit(self):
|
|
347
|
-
"""Commit changes to
|
|
564
|
+
"""Commit changes to a mounted volume.
|
|
348
565
|
|
|
349
566
|
If successful, the changes made are now persisted in durable storage and available to other containers accessing
|
|
350
567
|
the volume.
|
|
@@ -353,7 +570,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
353
570
|
req = api_pb2.VolumeCommitRequest(volume_id=self.object_id)
|
|
354
571
|
try:
|
|
355
572
|
# TODO(gongy): only apply indefinite retries on 504 status.
|
|
356
|
-
resp = await
|
|
573
|
+
resp = await self._client.stub.VolumeCommit(req, retry=Retry(max_retries=90))
|
|
357
574
|
if not resp.skip_reload:
|
|
358
575
|
# Reload changes on successful commit.
|
|
359
576
|
await self._do_reload(lock=False)
|
|
@@ -449,12 +666,14 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
449
666
|
req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
|
|
450
667
|
|
|
451
668
|
try:
|
|
452
|
-
response = await
|
|
669
|
+
response = await self._client.stub.VolumeGetFile2(req)
|
|
453
670
|
except modal.exception.NotFoundError as exc:
|
|
454
671
|
raise FileNotFoundError(exc.args[0])
|
|
455
672
|
|
|
673
|
+
@retry(n_attempts=5, base_delay=0.1, timeout=None)
|
|
456
674
|
async def read_block(block_url: str) -> bytes:
|
|
457
675
|
async with ClientSessionRegistry.get_session().get(block_url) as get_response:
|
|
676
|
+
get_response.raise_for_status()
|
|
458
677
|
return await get_response.content.read()
|
|
459
678
|
|
|
460
679
|
async def iter_urls() -> AsyncGenerator[str]:
|
|
@@ -470,33 +689,53 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
470
689
|
|
|
471
690
|
@live_method
|
|
472
691
|
async def read_file_into_fileobj(
|
|
473
|
-
self,
|
|
692
|
+
self,
|
|
693
|
+
path: str,
|
|
694
|
+
fileobj: typing.IO[bytes],
|
|
695
|
+
progress_cb: Optional[Callable[..., Any]] = None,
|
|
474
696
|
) -> int:
|
|
475
697
|
"""mdmd:hidden
|
|
476
698
|
Read volume file into file-like IO object.
|
|
477
699
|
"""
|
|
700
|
+
return await self._read_file_into_fileobj(path, fileobj, progress_cb=progress_cb)
|
|
701
|
+
|
|
702
|
+
@live_method
|
|
703
|
+
async def _read_file_into_fileobj(
|
|
704
|
+
self,
|
|
705
|
+
path: str,
|
|
706
|
+
fileobj: typing.IO[bytes],
|
|
707
|
+
concurrency: Optional[int] = None,
|
|
708
|
+
download_semaphore: Optional[asyncio.Semaphore] = None,
|
|
709
|
+
progress_cb: Optional[Callable[..., Any]] = None,
|
|
710
|
+
) -> int:
|
|
478
711
|
if progress_cb is None:
|
|
479
712
|
|
|
480
713
|
def progress_cb(*_, **__):
|
|
481
714
|
pass
|
|
482
715
|
|
|
716
|
+
if concurrency is None:
|
|
717
|
+
concurrency = multiprocessing.cpu_count()
|
|
718
|
+
|
|
483
719
|
req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
|
|
484
720
|
|
|
485
721
|
try:
|
|
486
|
-
response = await
|
|
722
|
+
response = await self._client.stub.VolumeGetFile2(req)
|
|
487
723
|
except modal.exception.NotFoundError as exc:
|
|
488
724
|
raise FileNotFoundError(exc.args[0])
|
|
489
725
|
|
|
490
|
-
|
|
491
|
-
|
|
726
|
+
if download_semaphore is None:
|
|
727
|
+
download_semaphore = asyncio.Semaphore(concurrency)
|
|
728
|
+
|
|
492
729
|
write_lock = asyncio.Lock()
|
|
493
730
|
start_pos = fileobj.tell()
|
|
494
731
|
|
|
732
|
+
@retry(n_attempts=5, base_delay=0.1, timeout=None)
|
|
495
733
|
async def download_block(idx, url) -> int:
|
|
496
734
|
block_start_pos = start_pos + idx * BLOCK_SIZE
|
|
497
735
|
num_bytes_written = 0
|
|
498
736
|
|
|
499
737
|
async with download_semaphore, ClientSessionRegistry.get_session().get(url) as get_response:
|
|
738
|
+
get_response.raise_for_status()
|
|
500
739
|
async for chunk in get_response.content.iter_any():
|
|
501
740
|
num_chunk_bytes_written = 0
|
|
502
741
|
|
|
@@ -530,10 +769,10 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
530
769
|
try:
|
|
531
770
|
if self._is_v1:
|
|
532
771
|
req = api_pb2.VolumeRemoveFileRequest(volume_id=self.object_id, path=path, recursive=recursive)
|
|
533
|
-
await
|
|
772
|
+
await self._client.stub.VolumeRemoveFile(req)
|
|
534
773
|
else:
|
|
535
774
|
req = api_pb2.VolumeRemoveFile2Request(volume_id=self.object_id, path=path, recursive=recursive)
|
|
536
|
-
await
|
|
775
|
+
await self._client.stub.VolumeRemoveFile2(req)
|
|
537
776
|
except modal.exception.NotFoundError as exc:
|
|
538
777
|
raise FileNotFoundError(exc.args[0])
|
|
539
778
|
|
|
@@ -572,12 +811,12 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
572
811
|
request = api_pb2.VolumeCopyFilesRequest(
|
|
573
812
|
volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path, recursive=recursive
|
|
574
813
|
)
|
|
575
|
-
await
|
|
814
|
+
await self._client.stub.VolumeCopyFiles(request, retry=Retry(base_delay=1))
|
|
576
815
|
else:
|
|
577
816
|
request = api_pb2.VolumeCopyFiles2Request(
|
|
578
817
|
volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path, recursive=recursive
|
|
579
818
|
)
|
|
580
|
-
await
|
|
819
|
+
await self._client.stub.VolumeCopyFiles2(request, retry=Retry(base_delay=1))
|
|
581
820
|
|
|
582
821
|
@live_method
|
|
583
822
|
async def batch_upload(self, force: bool = False) -> "_AbstractVolumeUploadContextManager":
|
|
@@ -607,15 +846,24 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
607
846
|
|
|
608
847
|
@live_method
|
|
609
848
|
async def _instance_delete(self):
|
|
610
|
-
await
|
|
611
|
-
self._client.stub.VolumeDelete, api_pb2.VolumeDeleteRequest(volume_id=self.object_id)
|
|
612
|
-
)
|
|
849
|
+
await self._client.stub.VolumeDelete(api_pb2.VolumeDeleteRequest(volume_id=self.object_id))
|
|
613
850
|
|
|
614
851
|
@staticmethod
|
|
615
852
|
async def delete(name: str, client: Optional[_Client] = None, environment_name: Optional[str] = None):
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
853
|
+
"""mdmd:hidden
|
|
854
|
+
Delete a named Volume.
|
|
855
|
+
|
|
856
|
+
Warning: This deletes an *entire Volume*, not just a specific file.
|
|
857
|
+
Deletion is irreversible and will affect any Apps currently using the Volume.
|
|
858
|
+
|
|
859
|
+
DEPRECATED: This method is deprecated; we recommend using `modal.Volume.objects.delete` instead.
|
|
860
|
+
|
|
861
|
+
"""
|
|
862
|
+
deprecation_warning(
|
|
863
|
+
(2025, 8, 6),
|
|
864
|
+
"`modal.Volume.delete` is deprecated; we recommend using `modal.Volume.objects.delete` instead.",
|
|
865
|
+
)
|
|
866
|
+
await _Volume.objects.delete(name, environment_name=environment_name, client=client)
|
|
619
867
|
|
|
620
868
|
@staticmethod
|
|
621
869
|
async def rename(
|
|
@@ -627,7 +875,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
|
627
875
|
):
|
|
628
876
|
obj = await _Volume.from_name(old_name, environment_name=environment_name).hydrate(client)
|
|
629
877
|
req = api_pb2.VolumeRenameRequest(volume_id=obj.object_id, name=new_name)
|
|
630
|
-
await
|
|
878
|
+
await obj._client.stub.VolumeRename(req)
|
|
631
879
|
|
|
632
880
|
|
|
633
881
|
Volume = synchronize_api(_Volume)
|
|
@@ -728,7 +976,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
|
|
|
728
976
|
disallow_overwrite_existing_files=not self._force,
|
|
729
977
|
)
|
|
730
978
|
try:
|
|
731
|
-
await
|
|
979
|
+
await self._client.stub.VolumePutFiles(request, retry=Retry(base_delay=1))
|
|
732
980
|
except GRPCError as exc:
|
|
733
981
|
raise FileExistsError(exc.message) if exc.status == Status.ALREADY_EXISTS else exc
|
|
734
982
|
|
|
@@ -788,7 +1036,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
|
|
|
788
1036
|
remote_filename = file_spec.mount_filename
|
|
789
1037
|
progress_task_id = self._progress_cb(name=remote_filename, size=file_spec.size)
|
|
790
1038
|
request = api_pb2.MountPutFileRequest(sha256_hex=file_spec.sha256_hex)
|
|
791
|
-
response = await
|
|
1039
|
+
response = await self._client.stub.MountPutFile(request, retry=Retry(base_delay=1))
|
|
792
1040
|
|
|
793
1041
|
start_time = time.monotonic()
|
|
794
1042
|
if not response.exists:
|
|
@@ -812,7 +1060,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
|
|
|
812
1060
|
self._progress_cb(task_id=progress_task_id, complete=True)
|
|
813
1061
|
|
|
814
1062
|
while (time.monotonic() - start_time) < VOLUME_PUT_FILE_CLIENT_TIMEOUT:
|
|
815
|
-
response = await
|
|
1063
|
+
response = await self._client.stub.MountPutFile(request2, retry=Retry(base_delay=1))
|
|
816
1064
|
if response.exists:
|
|
817
1065
|
break
|
|
818
1066
|
|
|
@@ -955,9 +1203,9 @@ class _VolumeUploadContextManager2(_AbstractVolumeUploadContextManager):
|
|
|
955
1203
|
for file_spec in file_specs:
|
|
956
1204
|
blocks = [
|
|
957
1205
|
api_pb2.VolumePutFiles2Request.Block(
|
|
958
|
-
contents_sha256=
|
|
1206
|
+
contents_sha256=block.contents_sha256, put_response=put_responses.get(block.contents_sha256)
|
|
959
1207
|
)
|
|
960
|
-
for
|
|
1208
|
+
for block in file_spec.blocks
|
|
961
1209
|
]
|
|
962
1210
|
files.append(
|
|
963
1211
|
api_pb2.VolumePutFiles2Request.File(
|
|
@@ -972,7 +1220,7 @@ class _VolumeUploadContextManager2(_AbstractVolumeUploadContextManager):
|
|
|
972
1220
|
)
|
|
973
1221
|
|
|
974
1222
|
try:
|
|
975
|
-
response = await
|
|
1223
|
+
response = await self._client.stub.VolumePutFiles2(request, retry=Retry(base_delay=1))
|
|
976
1224
|
except GRPCError as exc:
|
|
977
1225
|
raise FileExistsError(exc.message) if exc.status == Status.ALREADY_EXISTS else exc
|
|
978
1226
|
|
|
@@ -1014,7 +1262,7 @@ async def _put_missing_blocks(
|
|
|
1014
1262
|
# TODO(dflemstr): Type is `api_pb2.VolumePutFiles2Response.MissingBlock` but synchronicity gets confused
|
|
1015
1263
|
# by the nested class (?)
|
|
1016
1264
|
missing_block,
|
|
1017
|
-
) ->
|
|
1265
|
+
) -> tuple[bytes, bytes]:
|
|
1018
1266
|
# Lazily import to keep the eager loading time of this module down
|
|
1019
1267
|
from ._utils.bytes_io_segment_payload import BytesIOSegmentPayload
|
|
1020
1268
|
|
|
@@ -1023,9 +1271,7 @@ async def _put_missing_blocks(
|
|
|
1023
1271
|
file_spec = file_specs[missing_block.file_index]
|
|
1024
1272
|
# TODO(dflemstr): What if the underlying file has changed here in the meantime; should we check the
|
|
1025
1273
|
# hash again just to be sure?
|
|
1026
|
-
|
|
1027
|
-
block_start = missing_block.block_index * BLOCK_SIZE
|
|
1028
|
-
block_length = min(BLOCK_SIZE, file_spec.size - block_start)
|
|
1274
|
+
block = file_spec.blocks[missing_block.block_index]
|
|
1029
1275
|
|
|
1030
1276
|
if file_spec.path not in file_progresses:
|
|
1031
1277
|
file_task_id = progress_cb(name=file_spec.path, size=file_spec.size)
|
|
@@ -1035,7 +1281,7 @@ async def _put_missing_blocks(
|
|
|
1035
1281
|
file_progress.pending_blocks.add(missing_block.block_index)
|
|
1036
1282
|
task_progress_cb = functools.partial(progress_cb, task_id=file_progress.task_id)
|
|
1037
1283
|
|
|
1038
|
-
@retry(n_attempts=
|
|
1284
|
+
@retry(n_attempts=11, base_delay=0.5, timeout=None)
|
|
1039
1285
|
async def put_missing_block_attempt(payload: BytesIOSegmentPayload) -> bytes:
|
|
1040
1286
|
with payload.reset_on_error(subtract_progress=True):
|
|
1041
1287
|
async with ClientSessionRegistry.get_session().put(
|
|
@@ -1049,8 +1295,8 @@ async def _put_missing_blocks(
|
|
|
1049
1295
|
with file_spec.source() as source_fp:
|
|
1050
1296
|
payload = BytesIOSegmentPayload(
|
|
1051
1297
|
source_fp,
|
|
1052
|
-
|
|
1053
|
-
|
|
1298
|
+
block.start,
|
|
1299
|
+
block.end - block.start,
|
|
1054
1300
|
# limit chunk size somewhat to not keep event loop busy for too long
|
|
1055
1301
|
chunk_size=256 * 1024,
|
|
1056
1302
|
progress_report_cb=task_progress_cb,
|
|
@@ -1062,7 +1308,7 @@ async def _put_missing_blocks(
|
|
|
1062
1308
|
if len(file_progress.pending_blocks) == 0:
|
|
1063
1309
|
task_progress_cb(complete=True)
|
|
1064
1310
|
|
|
1065
|
-
return
|
|
1311
|
+
return block.contents_sha256, resp_data
|
|
1066
1312
|
|
|
1067
1313
|
tasks = [asyncio.create_task(put_missing_block(missing_block)) for missing_block in missing_blocks]
|
|
1068
1314
|
for task_result in asyncio.as_completed(tasks):
|