modal 0.62.16__py3-none-any.whl → 0.72.11__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.
- modal/__init__.py +17 -13
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +420 -937
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -59
- modal/_resources.py +51 -0
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1036 -0
- modal/_runtime/execution_context.py +89 -0
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +134 -9
- modal/_traceback.py +47 -187
- modal/_tunnel.py +52 -16
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +479 -100
- modal/_utils/blob_utils.py +157 -186
- modal/_utils/bytes_io_segment_payload.py +97 -0
- modal/_utils/deprecation.py +89 -0
- modal/_utils/docker_utils.py +98 -0
- modal/_utils/function_utils.py +460 -171
- modal/_utils/grpc_testing.py +47 -31
- modal/_utils/grpc_utils.py +62 -109
- modal/_utils/hash_utils.py +61 -19
- modal/_utils/http_utils.py +39 -9
- modal/_utils/logger.py +2 -1
- modal/_utils/mount_utils.py +34 -16
- modal/_utils/name_utils.py +58 -0
- modal/_utils/package_utils.py +14 -1
- modal/_utils/pattern_utils.py +205 -0
- modal/_utils/rand_pb_testing.py +5 -7
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +14 -12
- modal/app.py +1003 -314
- modal/app.pyi +540 -264
- modal/call_graph.py +7 -6
- modal/cli/_download.py +63 -53
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +205 -45
- modal/cli/config.py +12 -5
- modal/cli/container.py +62 -14
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +64 -58
- modal/cli/launch.py +32 -18
- modal/cli/network_file_system.py +64 -83
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +35 -10
- modal/cli/programs/vscode.py +60 -10
- modal/cli/queues.py +131 -0
- modal/cli/run.py +234 -131
- modal/cli/secret.py +8 -7
- modal/cli/token.py +7 -2
- modal/cli/utils.py +79 -10
- modal/cli/volume.py +110 -109
- modal/client.py +250 -144
- modal/client.pyi +157 -118
- modal/cloud_bucket_mount.py +108 -34
- modal/cloud_bucket_mount.pyi +32 -38
- modal/cls.py +535 -148
- modal/cls.pyi +190 -146
- modal/config.py +41 -19
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +111 -65
- modal/dict.pyi +136 -131
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +34 -43
- modal/experimental.py +61 -2
- modal/extensions/ipython.py +5 -5
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +197 -0
- modal/functions.py +906 -911
- modal/functions.pyi +466 -430
- modal/gpu.py +57 -44
- modal/image.py +1089 -479
- modal/image.pyi +584 -228
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +314 -101
- modal/mount.pyi +241 -235
- modal/network_file_system.py +92 -92
- modal/network_file_system.pyi +152 -110
- modal/object.py +67 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +434 -0
- modal/parallel_map.pyi +75 -0
- modal/partial_function.py +282 -117
- modal/partial_function.pyi +222 -129
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +182 -65
- modal/queue.pyi +218 -118
- modal/requirements/2024.04.txt +29 -0
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +48 -7
- modal/runner.py +459 -156
- modal/runner.pyi +135 -71
- modal/running_app.py +38 -0
- modal/sandbox.py +514 -236
- modal/sandbox.pyi +397 -169
- modal/schedule.py +4 -4
- modal/scheduler_placement.py +20 -3
- modal/secret.py +56 -31
- modal/secret.pyi +62 -42
- modal/serving.py +51 -56
- modal/serving.pyi +44 -36
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +285 -157
- modal/volume.pyi +249 -184
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
- modal_docs/gen_reference_docs.py +3 -1
- modal_docs/mdmd/mdmd.py +0 -1
- modal_docs/mdmd/signatures.py +5 -2
- modal_global_objects/images/base_images.py +28 -0
- modal_global_objects/mounts/python_standalone.py +2 -2
- modal_proto/__init__.py +1 -1
- modal_proto/api.proto +1288 -533
- modal_proto/api_grpc.py +856 -456
- modal_proto/api_pb2.py +2165 -1157
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1674 -855
- modal_proto/api_pb2_grpc.pyi +1416 -0
- modal_proto/modal_api_grpc.py +149 -0
- modal_proto/modal_options_grpc.py +3 -0
- modal_proto/options_pb2.pyi +20 -0
- modal_proto/options_pb2_grpc.pyi +7 -0
- modal_proto/py.typed +0 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- modal/_asgi.py +0 -370
- modal/_container_entrypoint.pyi +0 -378
- modal/_container_exec.py +0 -128
- modal/_sandbox_shell.py +0 -49
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal/stub.py +0 -783
- modal/stub.pyi +0 -332
- modal-0.62.16.dist-info/RECORD +0 -198
- modal_global_objects/images/conda.py +0 -15
- modal_global_objects/images/debian_slim.py +0 -15
- modal_global_objects/images/micromamba.py +0 -15
- test/__init__.py +0 -1
- test/aio_test.py +0 -12
- test/async_utils_test.py +0 -262
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -659
- test/client_test.py +0 -194
- test/cls_test.py +0 -630
- test/config_test.py +0 -137
- test/conftest.py +0 -1420
- test/container_app_test.py +0 -32
- test/container_test.py +0 -1389
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -33
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -653
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -141
- test/helpers.py +0 -42
- test/image_test.py +0 -669
- test/live_reload_test.py +0 -80
- test/lookup_test.py +0 -70
- test/mdmd_test.py +0 -329
- test/mount_test.py +0 -162
- test/mounted_files_test.py +0 -329
- test/network_file_system_test.py +0 -181
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -97
- test/resolver_test.py +0 -58
- test/retries_test.py +0 -67
- test/runner_test.py +0 -85
- test/sandbox_test.py +0 -191
- test/schedule_test.py +0 -15
- test/scheduler_placement_test.py +0 -29
- test/secret_test.py +0 -78
- test/serialization_test.py +0 -42
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -360
- test/test_asgi_wrapper.py +0 -234
- test/token_flow_test.py +0 -18
- test/traceback_test.py +0 -135
- test/tunnel_test.py +0 -29
- test/utils_test.py +0 -88
- test/version_test.py +0 -14
- test/volume_test.py +0 -341
- test/watcher_test.py +0 -30
- test/webhook_test.py +0 -146
- /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
- /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/volume.py
CHANGED
@@ -1,32 +1,34 @@
|
|
1
1
|
# Copyright Modal Labs 2023
|
2
2
|
import asyncio
|
3
3
|
import concurrent.futures
|
4
|
+
import enum
|
5
|
+
import functools
|
6
|
+
import os
|
7
|
+
import platform
|
8
|
+
import re
|
4
9
|
import time
|
5
|
-
|
10
|
+
import typing
|
11
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Sequence
|
12
|
+
from dataclasses import dataclass
|
6
13
|
from pathlib import Path, PurePosixPath
|
7
14
|
from typing import (
|
8
15
|
IO,
|
9
|
-
|
10
|
-
AsyncIterator,
|
16
|
+
Any,
|
11
17
|
BinaryIO,
|
12
18
|
Callable,
|
13
|
-
Generator,
|
14
|
-
List,
|
15
19
|
Optional,
|
16
|
-
Sequence,
|
17
|
-
Type,
|
18
20
|
Union,
|
19
21
|
)
|
20
22
|
|
21
|
-
import aiostream
|
22
23
|
from grpclib import GRPCError, Status
|
23
24
|
from synchronicity.async_wrap import asynccontextmanager
|
24
25
|
|
25
|
-
|
26
|
+
import modal_proto.api_pb2
|
27
|
+
from modal.exception import VolumeUploadTimeoutError
|
26
28
|
from modal_proto import api_pb2
|
27
29
|
|
28
30
|
from ._resolver import Resolver
|
29
|
-
from ._utils.async_utils import TaskContext, asyncnullcontext, synchronize_api
|
31
|
+
from ._utils.async_utils import TaskContext, aclosing, async_map, asyncnullcontext, synchronize_api
|
30
32
|
from ._utils.blob_utils import (
|
31
33
|
FileUploadSpec,
|
32
34
|
blob_iter,
|
@@ -34,14 +36,44 @@ from ._utils.blob_utils import (
|
|
34
36
|
get_file_upload_spec_from_fileobj,
|
35
37
|
get_file_upload_spec_from_path,
|
36
38
|
)
|
37
|
-
from ._utils.
|
39
|
+
from ._utils.deprecation import deprecation_error, deprecation_warning, renamed_parameter
|
40
|
+
from ._utils.grpc_utils import retry_transient_errors
|
41
|
+
from ._utils.name_utils import check_object_name
|
38
42
|
from .client import _Client
|
39
43
|
from .config import logger
|
40
44
|
from .object import EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, _get_environment_name, _Object, live_method, live_method_gen
|
41
45
|
|
42
46
|
# Max duration for uploading to volumes files
|
43
47
|
# As a guide, files >40GiB will take >10 minutes to upload.
|
44
|
-
VOLUME_PUT_FILE_CLIENT_TIMEOUT =
|
48
|
+
VOLUME_PUT_FILE_CLIENT_TIMEOUT = 60 * 60
|
49
|
+
|
50
|
+
|
51
|
+
class FileEntryType(enum.IntEnum):
|
52
|
+
"""Type of a file entry listed from a Modal volume."""
|
53
|
+
|
54
|
+
UNSPECIFIED = 0
|
55
|
+
FILE = 1
|
56
|
+
DIRECTORY = 2
|
57
|
+
SYMLINK = 3
|
58
|
+
|
59
|
+
|
60
|
+
@dataclass(frozen=True)
|
61
|
+
class FileEntry:
|
62
|
+
"""A file or directory entry listed from a Modal volume."""
|
63
|
+
|
64
|
+
path: str
|
65
|
+
type: FileEntryType
|
66
|
+
mtime: int
|
67
|
+
size: int
|
68
|
+
|
69
|
+
@classmethod
|
70
|
+
def _from_proto(cls, proto: api_pb2.FileEntry) -> "FileEntry":
|
71
|
+
return cls(
|
72
|
+
path=proto.path,
|
73
|
+
type=FileEntryType(proto.type),
|
74
|
+
mtime=proto.mtime,
|
75
|
+
size=proto.size,
|
76
|
+
)
|
45
77
|
|
46
78
|
|
47
79
|
class _Volume(_Object, type_prefix="vo"):
|
@@ -69,16 +101,16 @@ class _Volume(_Object, type_prefix="vo"):
|
|
69
101
|
```python
|
70
102
|
import modal
|
71
103
|
|
72
|
-
|
104
|
+
app = modal.App()
|
73
105
|
volume = modal.Volume.from_name("my-persisted-volume", create_if_missing=True)
|
74
106
|
|
75
|
-
@
|
107
|
+
@app.function(volumes={"/root/foo": volume})
|
76
108
|
def f():
|
77
109
|
with open("/root/foo/bar.txt", "w") as f:
|
78
110
|
f.write("hello")
|
79
111
|
volume.commit() # Persist changes
|
80
112
|
|
81
|
-
@
|
113
|
+
@app.function(volumes={"/root/foo": volume})
|
82
114
|
def g():
|
83
115
|
volume.reload() # Fetch latest changes
|
84
116
|
with open("/root/foo/bar.txt", "r") as f:
|
@@ -86,92 +118,84 @@ class _Volume(_Object, type_prefix="vo"):
|
|
86
118
|
```
|
87
119
|
"""
|
88
120
|
|
89
|
-
_lock: asyncio.Lock
|
121
|
+
_lock: Optional[asyncio.Lock] = None
|
90
122
|
|
91
|
-
def
|
123
|
+
async def _get_lock(self):
|
92
124
|
# To (mostly*) prevent multiple concurrent operations on the same volume, which can cause problems under
|
93
125
|
# some unlikely circumstances.
|
94
126
|
# *: You can bypass this by creating multiple handles to the same volume, e.g. via lookup. But this
|
95
127
|
# covers the typical case = good enough.
|
96
|
-
self._lock = asyncio.Lock()
|
97
128
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
async def _load(self: _Volume, resolver: Resolver, existing_object_id: Optional[str]):
|
107
|
-
status_row = resolver.add_status_row()
|
108
|
-
if existing_object_id:
|
109
|
-
# Volume already exists; do nothing.
|
110
|
-
self._hydrate(existing_object_id, resolver.client, None)
|
111
|
-
return
|
112
|
-
|
113
|
-
status_row.message("Creating volume...")
|
114
|
-
req = api_pb2.VolumeCreateRequest(app_id=resolver.app_id)
|
115
|
-
resp = await retry_transient_errors(resolver.client.stub.VolumeCreate, req)
|
116
|
-
status_row.finish("Created volume.")
|
117
|
-
self._hydrate(resp.volume_id, resolver.client, None)
|
118
|
-
|
119
|
-
return _Volume._from_loader(_load, "Volume()")
|
129
|
+
# Note: this function runs no async code but is marked as async to ensure it's
|
130
|
+
# being run inside the synchronicity event loop and binds the lock to the
|
131
|
+
# correct event loop on Python 3.9 which eagerly assigns event loops on
|
132
|
+
# constructions of locks
|
133
|
+
if self._lock is None:
|
134
|
+
self._lock = asyncio.Lock()
|
135
|
+
return self._lock
|
120
136
|
|
121
137
|
@staticmethod
|
138
|
+
@renamed_parameter((2024, 12, 18), "label", "name")
|
122
139
|
def from_name(
|
123
|
-
|
140
|
+
name: str,
|
124
141
|
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
|
125
142
|
environment_name: Optional[str] = None,
|
126
143
|
create_if_missing: bool = False,
|
144
|
+
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
127
145
|
) -> "_Volume":
|
128
|
-
"""
|
146
|
+
"""Reference a Volume by name, creating if necessary.
|
129
147
|
|
130
|
-
|
148
|
+
In contrast to `modal.Volume.lookup`, this is a lazy method
|
149
|
+
that defers hydrating the local object with metadata from
|
150
|
+
Modal servers until the first time is is actually used.
|
131
151
|
|
132
152
|
```python
|
133
|
-
|
153
|
+
vol = modal.Volume.from_name("my-volume", create_if_missing=True)
|
134
154
|
|
135
|
-
|
155
|
+
app = modal.App()
|
136
156
|
|
137
|
-
|
138
|
-
|
139
|
-
# Volume refers to the same object, even across instances of `stub`.
|
140
|
-
@stub.function(volumes={"/vol": volume})
|
157
|
+
# Volume refers to the same object, even across instances of `app`.
|
158
|
+
@app.function(volumes={"/data": vol})
|
141
159
|
def f():
|
142
160
|
pass
|
143
161
|
```
|
144
162
|
"""
|
163
|
+
check_object_name(name, "Volume")
|
145
164
|
|
146
165
|
async def _load(self: _Volume, resolver: Resolver, existing_object_id: Optional[str]):
|
147
166
|
req = api_pb2.VolumeGetOrCreateRequest(
|
148
|
-
deployment_name=
|
167
|
+
deployment_name=name,
|
149
168
|
namespace=namespace,
|
150
169
|
environment_name=_get_environment_name(environment_name, resolver),
|
151
170
|
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
171
|
+
version=version,
|
152
172
|
)
|
153
173
|
response = await resolver.client.stub.VolumeGetOrCreate(req)
|
154
174
|
self._hydrate(response.volume_id, resolver.client, None)
|
155
175
|
|
156
|
-
return _Volume._from_loader(_load, "Volume()")
|
176
|
+
return _Volume._from_loader(_load, "Volume()", hydrate_lazily=True)
|
157
177
|
|
158
178
|
@classmethod
|
159
179
|
@asynccontextmanager
|
160
180
|
async def ephemeral(
|
161
|
-
cls:
|
181
|
+
cls: type["_Volume"],
|
162
182
|
client: Optional[_Client] = None,
|
163
183
|
environment_name: Optional[str] = None,
|
184
|
+
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
164
185
|
_heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
|
165
|
-
) ->
|
186
|
+
) -> AsyncGenerator["_Volume", None]:
|
166
187
|
"""Creates a new ephemeral volume within a context manager:
|
167
188
|
|
168
189
|
Usage:
|
169
190
|
```python
|
170
|
-
|
171
|
-
|
191
|
+
import modal
|
192
|
+
with modal.Volume.ephemeral() as vol:
|
193
|
+
assert vol.listdir("/") == []
|
194
|
+
```
|
172
195
|
|
173
|
-
|
174
|
-
|
196
|
+
```python notest
|
197
|
+
async with modal.Volume.ephemeral() as vol:
|
198
|
+
assert await vol.listdir("/") == []
|
175
199
|
```
|
176
200
|
"""
|
177
201
|
if client is None:
|
@@ -179,6 +203,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
179
203
|
request = api_pb2.VolumeGetOrCreateRequest(
|
180
204
|
object_creation_type=api_pb2.OBJECT_CREATION_TYPE_EPHEMERAL,
|
181
205
|
environment_name=_get_environment_name(environment_name),
|
206
|
+
version=version,
|
182
207
|
)
|
183
208
|
response = await client.stub.VolumeGetOrCreate(request)
|
184
209
|
async with TaskContext() as tc:
|
@@ -187,33 +212,31 @@ class _Volume(_Object, type_prefix="vo"):
|
|
187
212
|
yield cls._new_hydrated(response.volume_id, client, None, is_another_app=True)
|
188
213
|
|
189
214
|
@staticmethod
|
190
|
-
|
191
|
-
label: str,
|
192
|
-
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
|
193
|
-
environment_name: Optional[str] = None,
|
194
|
-
cloud: Optional[str] = None,
|
195
|
-
) -> "_Volume":
|
196
|
-
"""Deprecated! Use `Volume.from_name(name, create_if_missing=True)`."""
|
197
|
-
deprecation_warning((2024, 3, 1), _Volume.persisted.__doc__)
|
198
|
-
return _Volume.from_name(label, namespace, environment_name, create_if_missing=True)
|
199
|
-
|
200
|
-
@staticmethod
|
215
|
+
@renamed_parameter((2024, 12, 18), "label", "name")
|
201
216
|
async def lookup(
|
202
|
-
|
217
|
+
name: str,
|
203
218
|
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
|
204
219
|
client: Optional[_Client] = None,
|
205
220
|
environment_name: Optional[str] = None,
|
206
221
|
create_if_missing: bool = False,
|
222
|
+
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
207
223
|
) -> "_Volume":
|
208
|
-
"""Lookup a
|
224
|
+
"""Lookup a named Volume.
|
209
225
|
|
210
|
-
|
211
|
-
|
212
|
-
|
226
|
+
In contrast to `modal.Volume.from_name`, this is an eager method
|
227
|
+
that will hydrate the local object with metadata from Modal servers.
|
228
|
+
|
229
|
+
```python notest
|
230
|
+
vol = modal.Volume.lookup("my-volume")
|
231
|
+
print(vol.listdir("/"))
|
213
232
|
```
|
214
233
|
"""
|
215
234
|
obj = _Volume.from_name(
|
216
|
-
|
235
|
+
name,
|
236
|
+
namespace=namespace,
|
237
|
+
environment_name=environment_name,
|
238
|
+
create_if_missing=create_if_missing,
|
239
|
+
version=version,
|
217
240
|
)
|
218
241
|
if client is None:
|
219
242
|
client = await _Client.from_env()
|
@@ -227,8 +250,10 @@ class _Volume(_Object, type_prefix="vo"):
|
|
227
250
|
namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
|
228
251
|
client: Optional[_Client] = None,
|
229
252
|
environment_name: Optional[str] = None,
|
253
|
+
version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
|
230
254
|
) -> str:
|
231
255
|
"""mdmd:hidden"""
|
256
|
+
check_object_name(deployment_name, "Volume")
|
232
257
|
if client is None:
|
233
258
|
client = await _Client.from_env()
|
234
259
|
request = api_pb2.VolumeGetOrCreateRequest(
|
@@ -236,13 +261,14 @@ class _Volume(_Object, type_prefix="vo"):
|
|
236
261
|
namespace=namespace,
|
237
262
|
environment_name=_get_environment_name(environment_name),
|
238
263
|
object_creation_type=api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS,
|
264
|
+
version=version,
|
239
265
|
)
|
240
266
|
resp = await retry_transient_errors(client.stub.VolumeGetOrCreate, request)
|
241
267
|
return resp.volume_id
|
242
268
|
|
243
269
|
@live_method
|
244
270
|
async def _do_reload(self, lock=True):
|
245
|
-
async with self.
|
271
|
+
async with (await self._get_lock()) if lock else asyncnullcontext():
|
246
272
|
req = api_pb2.VolumeReloadRequest(volume_id=self.object_id)
|
247
273
|
_ = await retry_transient_errors(self._client.stub.VolumeReload, req)
|
248
274
|
|
@@ -253,7 +279,7 @@ class _Volume(_Object, type_prefix="vo"):
|
|
253
279
|
If successful, the changes made are now persisted in durable storage and available to other containers accessing
|
254
280
|
the volume.
|
255
281
|
"""
|
256
|
-
async with self.
|
282
|
+
async with await self._get_lock():
|
257
283
|
req = api_pb2.VolumeCommitRequest(volume_id=self.object_id)
|
258
284
|
try:
|
259
285
|
# TODO(gongy): only apply indefinite retries on 504 status.
|
@@ -276,33 +302,68 @@ class _Volume(_Object, type_prefix="vo"):
|
|
276
302
|
try:
|
277
303
|
await self._do_reload()
|
278
304
|
except GRPCError as exc:
|
305
|
+
# TODO(staffan): This is brittle and janky, as it relies on specific paths and error messages which can
|
306
|
+
# change server-side at any time. Consider returning the open files directly in the error emitted from the
|
307
|
+
# server.
|
308
|
+
if exc.message == "there are open files preventing the operation":
|
309
|
+
# Attempt to identify what open files are problematic and include information about the first (to avoid
|
310
|
+
# really verbose errors) open file in the error message to help troubleshooting.
|
311
|
+
# This is best-effort and not necessarily bulletproof, as the view of open files inside the container
|
312
|
+
# might differ from that outside - but it will at least catch common errors.
|
313
|
+
vol_path = f"/__modal/volumes/{self.object_id}"
|
314
|
+
annotation = _open_files_error_annotation(vol_path)
|
315
|
+
if annotation:
|
316
|
+
raise RuntimeError(f"{exc.message}: {annotation}")
|
317
|
+
|
279
318
|
raise RuntimeError(exc.message) if exc.status in (Status.FAILED_PRECONDITION, Status.NOT_FOUND) else exc
|
280
319
|
|
281
320
|
@live_method_gen
|
282
|
-
async def iterdir(self, path: str) -> AsyncIterator[
|
321
|
+
async def iterdir(self, path: str, *, recursive: bool = True) -> AsyncIterator[FileEntry]:
|
283
322
|
"""Iterate over all files in a directory in the volume.
|
284
323
|
|
285
|
-
|
286
|
-
|
287
|
-
|
324
|
+
Passing a directory path lists all files in the directory. For a file path, return only that
|
325
|
+
file's description. If `recursive` is set to True, list all files and folders under the path
|
326
|
+
recursively.
|
288
327
|
"""
|
289
|
-
|
290
|
-
|
328
|
+
from modal_version import major_number, minor_number
|
329
|
+
|
330
|
+
# This allows us to remove the server shim after 0.62 is no longer supported.
|
331
|
+
deprecation = deprecation_warning if (major_number, minor_number) <= (0, 62) else deprecation_error
|
332
|
+
if path.endswith("**"):
|
333
|
+
msg = (
|
334
|
+
"Glob patterns in `volume get` and `Volume.listdir()` are deprecated. "
|
335
|
+
"Please pass recursive=True instead. For the CLI, just remove the glob suffix."
|
336
|
+
)
|
337
|
+
deprecation(
|
338
|
+
(2024, 4, 23),
|
339
|
+
msg,
|
340
|
+
)
|
341
|
+
elif path.endswith("*"):
|
342
|
+
deprecation(
|
343
|
+
(2024, 4, 23),
|
344
|
+
(
|
345
|
+
"Glob patterns in `volume get` and `Volume.listdir()` are deprecated. "
|
346
|
+
"Please remove the glob `*` suffix."
|
347
|
+
),
|
348
|
+
)
|
349
|
+
|
350
|
+
req = api_pb2.VolumeListFilesRequest(volume_id=self.object_id, path=path, recursive=recursive)
|
351
|
+
async for batch in self._client.stub.VolumeListFiles.unary_stream(req):
|
291
352
|
for entry in batch.entries:
|
292
|
-
yield entry
|
353
|
+
yield FileEntry._from_proto(entry)
|
293
354
|
|
294
355
|
@live_method
|
295
|
-
async def listdir(self, path: str) ->
|
356
|
+
async def listdir(self, path: str, *, recursive: bool = False) -> list[FileEntry]:
|
296
357
|
"""List all files under a path prefix in the modal.Volume.
|
297
358
|
|
298
|
-
|
299
|
-
|
300
|
-
|
359
|
+
Passing a directory path lists all files in the directory. For a file path, return only that
|
360
|
+
file's description. If `recursive` is set to True, list all files and folders under the path
|
361
|
+
recursively.
|
301
362
|
"""
|
302
|
-
return [entry async for entry in self.iterdir(path)]
|
363
|
+
return [entry async for entry in self.iterdir(path, recursive=recursive)]
|
303
364
|
|
304
365
|
@live_method_gen
|
305
|
-
async def read_file(self, path:
|
366
|
+
async def read_file(self, path: str) -> AsyncIterator[bytes]:
|
306
367
|
"""
|
307
368
|
Read a file from the modal.Volume.
|
308
369
|
|
@@ -316,8 +377,6 @@ class _Volume(_Object, type_prefix="vo"):
|
|
316
377
|
print(len(data)) # == 1024 * 1024
|
317
378
|
```
|
318
379
|
"""
|
319
|
-
if isinstance(path, str):
|
320
|
-
path = path.encode("utf-8")
|
321
380
|
req = api_pb2.VolumeGetFileRequest(volume_id=self.object_id, path=path)
|
322
381
|
try:
|
323
382
|
response = await retry_transient_errors(self._client.stub.VolumeGetFile, req)
|
@@ -332,24 +391,12 @@ class _Volume(_Object, type_prefix="vo"):
|
|
332
391
|
yield data
|
333
392
|
|
334
393
|
@live_method
|
335
|
-
async def read_file_into_fileobj(self, path:
|
394
|
+
async def read_file_into_fileobj(self, path: str, fileobj: IO[bytes]) -> int:
|
336
395
|
"""mdmd:hidden
|
337
396
|
|
338
|
-
Read volume file into file-like IO object
|
339
|
-
|
397
|
+
Read volume file into file-like IO object.
|
398
|
+
In the future, this will replace the current generator implementation of the `read_file` method.
|
340
399
|
"""
|
341
|
-
if isinstance(path, str):
|
342
|
-
path = path.encode("utf-8")
|
343
|
-
|
344
|
-
if progress:
|
345
|
-
from ._output import download_progress_bar
|
346
|
-
|
347
|
-
progress_bar = download_progress_bar()
|
348
|
-
task_id = progress_bar.add_task("download", path=path.decode(), start=False)
|
349
|
-
progress_bar.console.log(f"Requesting {path.decode()}")
|
350
|
-
else:
|
351
|
-
progress_bar = nullcontext()
|
352
|
-
task_id = None
|
353
400
|
|
354
401
|
chunk_size_bytes = 8 * 1024 * 1024
|
355
402
|
start = 0
|
@@ -363,67 +410,65 @@ class _Volume(_Object, type_prefix="vo"):
|
|
363
410
|
|
364
411
|
n = fileobj.write(response.data)
|
365
412
|
if n != len(response.data):
|
366
|
-
raise
|
413
|
+
raise OSError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
|
367
414
|
elif n == response.size:
|
368
|
-
if progress:
|
369
|
-
progress_bar.console.log(f"Wrote {n} bytes to '{path.decode()}'")
|
370
415
|
return response.size
|
371
416
|
elif n > response.size:
|
372
417
|
raise RuntimeError(f"length of returned data exceeds reported filesize: {n} > {response.size}")
|
373
418
|
# else: there's more data to read. continue reading with further ranged GET requests.
|
374
|
-
start = n
|
375
419
|
file_size = response.size
|
376
420
|
written = n
|
377
421
|
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
)
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
n = fileobj.write(response.data)
|
396
|
-
if n != len(response.data):
|
397
|
-
raise IOError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
|
398
|
-
start += n
|
399
|
-
written += n
|
400
|
-
if progress:
|
401
|
-
progress_bar.update(task_id, advance=n)
|
402
|
-
if written == file_size:
|
403
|
-
break
|
422
|
+
while True:
|
423
|
+
req = api_pb2.VolumeGetFileRequest(volume_id=self.object_id, path=path, start=written, len=chunk_size_bytes)
|
424
|
+
response = await retry_transient_errors(self._client.stub.VolumeGetFile, req)
|
425
|
+
if response.WhichOneof("data_oneof") != "data":
|
426
|
+
raise RuntimeError("expected to receive 'data' in response")
|
427
|
+
if len(response.data) > chunk_size_bytes:
|
428
|
+
raise RuntimeError(f"received more data than requested: {len(response.data)} > {chunk_size_bytes}")
|
429
|
+
elif (written + len(response.data)) > file_size:
|
430
|
+
raise RuntimeError(f"received data exceeds filesize of {chunk_size_bytes}")
|
431
|
+
|
432
|
+
n = fileobj.write(response.data)
|
433
|
+
if n != len(response.data):
|
434
|
+
raise OSError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
|
435
|
+
written += n
|
436
|
+
if written == file_size:
|
437
|
+
break
|
404
438
|
|
405
|
-
if progress:
|
406
|
-
progress_bar.console.log(f"Wrote {written} bytes to '{path.decode()}'")
|
407
439
|
return written
|
408
440
|
|
409
441
|
@live_method
|
410
|
-
async def remove_file(self, path:
|
442
|
+
async def remove_file(self, path: str, recursive: bool = False) -> None:
|
411
443
|
"""Remove a file or directory from a volume."""
|
412
|
-
if isinstance(path, str):
|
413
|
-
path = path.encode("utf-8")
|
414
444
|
req = api_pb2.VolumeRemoveFileRequest(volume_id=self.object_id, path=path, recursive=recursive)
|
415
445
|
await retry_transient_errors(self._client.stub.VolumeRemoveFile, req)
|
416
446
|
|
417
447
|
@live_method
|
418
|
-
async def copy_files(self, src_paths: Sequence[
|
448
|
+
async def copy_files(self, src_paths: Sequence[str], dst_path: str) -> None:
|
419
449
|
"""
|
420
450
|
Copy files within the volume from src_paths to dst_path.
|
421
451
|
The semantics of the copy operation follow those of the UNIX cp command.
|
422
|
-
"""
|
423
|
-
src_paths = [path.encode("utf-8") for path in src_paths if isinstance(path, str)]
|
424
|
-
if isinstance(dst_path, str):
|
425
|
-
dst_path = dst_path.encode("utf-8")
|
426
452
|
|
453
|
+
The `src_paths` parameter is a list. If you want to copy a single file, you should pass a list with a
|
454
|
+
single element.
|
455
|
+
|
456
|
+
`src_paths` and `dst_path` should refer to the desired location *inside* the volume. You do not need to prepend
|
457
|
+
the volume mount path.
|
458
|
+
|
459
|
+
**Usage**
|
460
|
+
|
461
|
+
```python notest
|
462
|
+
vol = modal.Volume.lookup("my-modal-volume")
|
463
|
+
|
464
|
+
vol.copy_files(["bar/example.txt"], "bar2") # Copy files to another directory
|
465
|
+
vol.copy_files(["bar/example.txt"], "bar/example2.txt") # Rename a file by copying
|
466
|
+
```
|
467
|
+
|
468
|
+
Note that if the volume is already mounted on the Modal function, you should use normal filesystem operations
|
469
|
+
like `os.rename()` and then `commit()` the volume. The `copy_files()` method is useful when you don't have
|
470
|
+
the volume mounted as a filesystem, e.g. when running a script on your local computer.
|
471
|
+
"""
|
427
472
|
request = api_pb2.VolumeCopyFilesRequest(volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path)
|
428
473
|
await retry_transient_errors(self._client.stub.VolumeCopyFiles, request, base_delay=1)
|
429
474
|
|
@@ -449,11 +494,30 @@ class _Volume(_Object, type_prefix="vo"):
|
|
449
494
|
return _VolumeUploadContextManager(self.object_id, self._client, force=force)
|
450
495
|
|
451
496
|
@live_method
|
452
|
-
async def
|
497
|
+
async def _instance_delete(self):
|
453
498
|
await retry_transient_errors(
|
454
499
|
self._client.stub.VolumeDelete, api_pb2.VolumeDeleteRequest(volume_id=self.object_id)
|
455
500
|
)
|
456
501
|
|
502
|
+
@staticmethod
|
503
|
+
@renamed_parameter((2024, 12, 18), "label", "name")
|
504
|
+
async def delete(name: str, client: Optional[_Client] = None, environment_name: Optional[str] = None):
|
505
|
+
obj = await _Volume.lookup(name, client=client, environment_name=environment_name)
|
506
|
+
req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
|
507
|
+
await retry_transient_errors(obj._client.stub.VolumeDelete, req)
|
508
|
+
|
509
|
+
@staticmethod
|
510
|
+
async def rename(
|
511
|
+
old_name: str,
|
512
|
+
new_name: str,
|
513
|
+
*,
|
514
|
+
client: Optional[_Client] = None,
|
515
|
+
environment_name: Optional[str] = None,
|
516
|
+
):
|
517
|
+
obj = await _Volume.lookup(old_name, client=client, environment_name=environment_name)
|
518
|
+
req = api_pb2.VolumeRenameRequest(volume_id=obj.object_id, name=new_name)
|
519
|
+
await retry_transient_errors(obj._client.stub.VolumeRename, req)
|
520
|
+
|
457
521
|
|
458
522
|
class _VolumeUploadContextManager:
|
459
523
|
"""Context manager for batch-uploading files to a Volume."""
|
@@ -461,13 +525,17 @@ class _VolumeUploadContextManager:
|
|
461
525
|
_volume_id: str
|
462
526
|
_client: _Client
|
463
527
|
_force: bool
|
464
|
-
|
528
|
+
progress_cb: Callable[..., Any]
|
529
|
+
_upload_generators: list[Generator[Callable[[], FileUploadSpec], None, None]]
|
465
530
|
|
466
|
-
def __init__(
|
531
|
+
def __init__(
|
532
|
+
self, volume_id: str, client: _Client, progress_cb: Optional[Callable[..., Any]] = None, force: bool = False
|
533
|
+
):
|
467
534
|
"""mdmd:hidden"""
|
468
535
|
self._volume_id = volume_id
|
469
536
|
self._client = client
|
470
537
|
self._upload_generators = []
|
538
|
+
self._progress_cb = progress_cb or (lambda *_, **__: None)
|
471
539
|
self._force = force
|
472
540
|
|
473
541
|
async def __aenter__(self):
|
@@ -489,11 +557,13 @@ class _VolumeUploadContextManager:
|
|
489
557
|
for fut in asyncio.as_completed(futs):
|
490
558
|
yield await fut
|
491
559
|
|
492
|
-
# Compute checksums
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
560
|
+
# Compute checksums & Upload files
|
561
|
+
files: list[api_pb2.MountFile] = []
|
562
|
+
async with aclosing(async_map(gen_file_upload_specs(), self._upload_file, concurrency=20)) as stream:
|
563
|
+
async for item in stream:
|
564
|
+
files.append(item)
|
565
|
+
|
566
|
+
self._progress_cb(complete=True)
|
497
567
|
|
498
568
|
request = api_pb2.VolumePutFilesRequest(
|
499
569
|
volume_id=self._volume_id,
|
@@ -559,7 +629,7 @@ class _VolumeUploadContextManager:
|
|
559
629
|
|
560
630
|
async def _upload_file(self, file_spec: FileUploadSpec) -> api_pb2.MountFile:
|
561
631
|
remote_filename = file_spec.mount_filename
|
562
|
-
|
632
|
+
progress_task_id = self._progress_cb(name=remote_filename, size=file_spec.size)
|
563
633
|
request = api_pb2.MountPutFileRequest(sha256_hex=file_spec.sha256_hex)
|
564
634
|
response = await retry_transient_errors(self._client.stub.MountPutFile, request, base_delay=1)
|
565
635
|
|
@@ -568,7 +638,13 @@ class _VolumeUploadContextManager:
|
|
568
638
|
if file_spec.use_blob:
|
569
639
|
logger.debug(f"Creating blob file for {file_spec.source_description} ({file_spec.size} bytes)")
|
570
640
|
with file_spec.source() as fp:
|
571
|
-
blob_id = await blob_upload_file(
|
641
|
+
blob_id = await blob_upload_file(
|
642
|
+
fp,
|
643
|
+
self._client.stub,
|
644
|
+
functools.partial(self._progress_cb, progress_task_id),
|
645
|
+
sha256_hex=file_spec.sha256_hex,
|
646
|
+
md5_hex=file_spec.md5_hex,
|
647
|
+
)
|
572
648
|
logger.debug(f"Uploading blob file {file_spec.source_description} as {remote_filename}")
|
573
649
|
request2 = api_pb2.MountPutFileRequest(data_blob_id=blob_id, sha256_hex=file_spec.sha256_hex)
|
574
650
|
else:
|
@@ -576,6 +652,7 @@ class _VolumeUploadContextManager:
|
|
576
652
|
f"Uploading file {file_spec.source_description} to {remote_filename} ({file_spec.size} bytes)"
|
577
653
|
)
|
578
654
|
request2 = api_pb2.MountPutFileRequest(data=file_spec.content, sha256_hex=file_spec.sha256_hex)
|
655
|
+
self._progress_cb(task_id=progress_task_id, complete=True)
|
579
656
|
|
580
657
|
while (time.monotonic() - start_time) < VOLUME_PUT_FILE_CLIENT_TIMEOUT:
|
581
658
|
response = await retry_transient_errors(self._client.stub.MountPutFile, request2, base_delay=1)
|
@@ -584,7 +661,8 @@ class _VolumeUploadContextManager:
|
|
584
661
|
|
585
662
|
if not response.exists:
|
586
663
|
raise VolumeUploadTimeoutError(f"Uploading of {file_spec.source_description} timed out")
|
587
|
-
|
664
|
+
else:
|
665
|
+
self._progress_cb(task_id=progress_task_id, complete=True)
|
588
666
|
return api_pb2.MountFile(
|
589
667
|
filename=remote_filename,
|
590
668
|
sha256_hex=file_spec.sha256_hex,
|
@@ -594,3 +672,53 @@ class _VolumeUploadContextManager:
|
|
594
672
|
|
595
673
|
Volume = synchronize_api(_Volume)
|
596
674
|
VolumeUploadContextManager = synchronize_api(_VolumeUploadContextManager)
|
675
|
+
|
676
|
+
|
677
|
+
def _open_files_error_annotation(mount_path: str) -> Optional[str]:
|
678
|
+
if platform.system() != "Linux":
|
679
|
+
return None
|
680
|
+
|
681
|
+
self_pid = os.readlink("/proc/self")
|
682
|
+
|
683
|
+
def find_open_file_for_pid(pid: str) -> Optional[str]:
|
684
|
+
# /proc/{pid}/cmdline is null separated
|
685
|
+
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
686
|
+
raw = f.read()
|
687
|
+
parts = raw.split(b"\0")
|
688
|
+
cmdline = " ".join([part.decode() for part in parts]).rstrip(" ")
|
689
|
+
|
690
|
+
cwd = PurePosixPath(os.readlink(f"/proc/{pid}/cwd"))
|
691
|
+
if cwd.is_relative_to(mount_path):
|
692
|
+
if pid == self_pid:
|
693
|
+
return "cwd is inside volume"
|
694
|
+
else:
|
695
|
+
return f"cwd of '{cmdline}' is inside volume"
|
696
|
+
|
697
|
+
for fd in os.listdir(f"/proc/{pid}/fd"):
|
698
|
+
try:
|
699
|
+
path = PurePosixPath(os.readlink(f"/proc/{pid}/fd/{fd}"))
|
700
|
+
try:
|
701
|
+
rel_path = path.relative_to(mount_path)
|
702
|
+
if pid == self_pid:
|
703
|
+
return f"path {rel_path} is open"
|
704
|
+
else:
|
705
|
+
return f"path {rel_path} is open from '{cmdline}'"
|
706
|
+
except ValueError:
|
707
|
+
pass
|
708
|
+
|
709
|
+
except FileNotFoundError:
|
710
|
+
# File was closed
|
711
|
+
pass
|
712
|
+
return None
|
713
|
+
|
714
|
+
pid_re = re.compile("^[1-9][0-9]*$")
|
715
|
+
for dirent in os.listdir("/proc/"):
|
716
|
+
if pid_re.match(dirent):
|
717
|
+
try:
|
718
|
+
annotation = find_open_file_for_pid(dirent)
|
719
|
+
if annotation:
|
720
|
+
return annotation
|
721
|
+
except (FileNotFoundError, PermissionError):
|
722
|
+
pass
|
723
|
+
|
724
|
+
return None
|