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.
Files changed (220) hide show
  1. modal/__init__.py +17 -13
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +420 -937
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +5 -7
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {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
- from contextlib import nullcontext
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
- AsyncGenerator,
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
- from modal.exception import VolumeUploadTimeoutError, deprecation_warning
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.grpc_utils import retry_transient_errors, unary_stream
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 = 30 * 60
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
- stub = modal.Stub()
104
+ app = modal.App()
73
105
  volume = modal.Volume.from_name("my-persisted-volume", create_if_missing=True)
74
106
 
75
- @stub.function(volumes={"/root/foo": volume})
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
- @stub.function(volumes={"/root/foo": volume})
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 _initialize_from_empty(self):
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
- @staticmethod
99
- def new() -> "_Volume":
100
- """`Volume.new` is deprecated.
101
-
102
- Please use `Volume.from_name` (for persisted) or `Volume.ephemeral` (for ephemeral) volumes.
103
- """
104
- deprecation_warning((2024, 3, 20), Volume.new.__doc__)
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
- label: str,
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
- """Create a reference to a persisted volume. Optionally create it lazily.
146
+ """Reference a Volume by name, creating if necessary.
129
147
 
130
- **Example Usage**
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
- import modal
153
+ vol = modal.Volume.from_name("my-volume", create_if_missing=True)
134
154
 
135
- volume = modal.Volume.from_name("my-volume", create_if_missing=True)
155
+ app = modal.App()
136
156
 
137
- stub = modal.Stub()
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=label,
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: Type["_Volume"],
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
- ) -> AsyncIterator["_Volume"]:
186
+ ) -> AsyncGenerator["_Volume", None]:
166
187
  """Creates a new ephemeral volume within a context manager:
167
188
 
168
189
  Usage:
169
190
  ```python
170
- with Volume.ephemeral() as vol:
171
- assert vol.listdir() == []
191
+ import modal
192
+ with modal.Volume.ephemeral() as vol:
193
+ assert vol.listdir("/") == []
194
+ ```
172
195
 
173
- async with Volume.ephemeral() as vol:
174
- assert await vol.listdir() == []
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
- def persisted(
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
- label: str,
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 volume with a given name
224
+ """Lookup a named Volume.
209
225
 
210
- ```python
211
- n = modal.Volume.lookup("my-volume")
212
- print(n.listdir("/"))
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
- label, namespace=namespace, environment_name=environment_name, create_if_missing=create_if_missing
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._lock if lock else asyncnullcontext():
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._lock:
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[api_pb2.VolumeListFilesEntry]:
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
- * Passing a directory path lists all files in the directory (names are relative to the directory)
286
- * Passing a file path returns a list containing only that file's listing description
287
- * Passing a glob path (including at least one * or ** sequence) returns all files matching that glob path (using absolute paths)
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
- req = api_pb2.VolumeListFilesRequest(volume_id=self.object_id, path=path)
290
- async for batch in unary_stream(self._client.stub.VolumeListFiles, req):
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) -> List[api_pb2.VolumeListFilesEntry]:
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
- * Passing a directory path lists all files in the directory
299
- * Passing a file path returns a list containing only that file's listing description
300
- * Passing a glob path (including at least one * or ** sequence) returns all files matching that glob path (using absolute paths)
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: Union[str, bytes]) -> AsyncIterator[bytes]:
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: Union[str, bytes], fileobj: IO[bytes], progress: bool = False) -> int:
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, with support for progress display.
339
- Used by modal CLI. In future will replace current generator implementation of `read_file` method.
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 IOError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
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
- if progress:
379
- progress_bar.update(task_id, total=int(file_size))
380
- progress_bar.start_task(task_id)
381
-
382
- with progress_bar:
383
- while True:
384
- req = api_pb2.VolumeGetFileRequest(
385
- volume_id=self.object_id, path=path, start=start, len=chunk_size_bytes
386
- )
387
- response = await retry_transient_errors(self._client.stub.VolumeGetFile, req)
388
- if response.WhichOneof("data_oneof") != "data":
389
- raise RuntimeError("expected to receive 'data' in response")
390
- if len(response.data) > chunk_size_bytes:
391
- raise RuntimeError(f"received more data than requested: {len(response.data)} > {chunk_size_bytes}")
392
- elif (written + len(response.data)) > file_size:
393
- raise RuntimeError(f"received data exceeds filesize of {chunk_size_bytes}")
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: Union[str, bytes], recursive: bool = False) -> None:
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[Union[str, bytes]], dst_path: Union[str, bytes]) -> None:
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 delete(self):
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
- _upload_generators: List[Generator[Callable[[], FileUploadSpec], None, None]]
528
+ progress_cb: Callable[..., Any]
529
+ _upload_generators: list[Generator[Callable[[], FileUploadSpec], None, None]]
465
530
 
466
- def __init__(self, volume_id: str, client: _Client, force: bool = False):
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
- files_stream = aiostream.stream.iterate(gen_file_upload_specs())
494
- # Upload files
495
- uploads_stream = aiostream.stream.map(files_stream, self._upload_file, task_limit=20)
496
- files: List[api_pb2.MountFile] = await aiostream.stream.list(uploads_stream)
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(fp, self._client.stub)
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