modal 1.0.3.dev10__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.

Files changed (160) hide show
  1. modal/__init__.py +0 -2
  2. modal/__main__.py +3 -4
  3. modal/_billing.py +80 -0
  4. modal/_clustered_functions.py +7 -3
  5. modal/_clustered_functions.pyi +15 -3
  6. modal/_container_entrypoint.py +51 -69
  7. modal/_functions.py +508 -240
  8. modal/_grpc_client.py +171 -0
  9. modal/_load_context.py +105 -0
  10. modal/_object.py +81 -21
  11. modal/_output.py +58 -45
  12. modal/_partial_function.py +48 -73
  13. modal/_pty.py +7 -3
  14. modal/_resolver.py +26 -46
  15. modal/_runtime/asgi.py +4 -3
  16. modal/_runtime/container_io_manager.py +358 -220
  17. modal/_runtime/container_io_manager.pyi +296 -101
  18. modal/_runtime/execution_context.py +18 -2
  19. modal/_runtime/execution_context.pyi +64 -7
  20. modal/_runtime/gpu_memory_snapshot.py +262 -57
  21. modal/_runtime/user_code_imports.py +28 -58
  22. modal/_serialization.py +90 -6
  23. modal/_traceback.py +42 -1
  24. modal/_tunnel.pyi +380 -12
  25. modal/_utils/async_utils.py +84 -29
  26. modal/_utils/auth_token_manager.py +111 -0
  27. modal/_utils/blob_utils.py +181 -58
  28. modal/_utils/deprecation.py +19 -0
  29. modal/_utils/function_utils.py +91 -47
  30. modal/_utils/grpc_utils.py +89 -66
  31. modal/_utils/mount_utils.py +26 -1
  32. modal/_utils/name_utils.py +17 -3
  33. modal/_utils/task_command_router_client.py +536 -0
  34. modal/_utils/time_utils.py +34 -6
  35. modal/app.py +256 -88
  36. modal/app.pyi +909 -92
  37. modal/billing.py +5 -0
  38. modal/builder/2025.06.txt +18 -0
  39. modal/builder/PREVIEW.txt +18 -0
  40. modal/builder/base-images.json +58 -0
  41. modal/cli/_download.py +19 -3
  42. modal/cli/_traceback.py +3 -2
  43. modal/cli/app.py +4 -4
  44. modal/cli/cluster.py +15 -7
  45. modal/cli/config.py +5 -3
  46. modal/cli/container.py +7 -6
  47. modal/cli/dict.py +22 -16
  48. modal/cli/entry_point.py +12 -5
  49. modal/cli/environment.py +5 -4
  50. modal/cli/import_refs.py +3 -3
  51. modal/cli/launch.py +102 -5
  52. modal/cli/network_file_system.py +11 -12
  53. modal/cli/profile.py +3 -2
  54. modal/cli/programs/launch_instance_ssh.py +94 -0
  55. modal/cli/programs/run_jupyter.py +1 -1
  56. modal/cli/programs/run_marimo.py +95 -0
  57. modal/cli/programs/vscode.py +1 -1
  58. modal/cli/queues.py +57 -26
  59. modal/cli/run.py +91 -23
  60. modal/cli/secret.py +48 -22
  61. modal/cli/token.py +7 -8
  62. modal/cli/utils.py +4 -7
  63. modal/cli/volume.py +31 -25
  64. modal/client.py +15 -85
  65. modal/client.pyi +183 -62
  66. modal/cloud_bucket_mount.py +5 -3
  67. modal/cloud_bucket_mount.pyi +197 -5
  68. modal/cls.py +200 -126
  69. modal/cls.pyi +446 -68
  70. modal/config.py +29 -11
  71. modal/container_process.py +319 -19
  72. modal/container_process.pyi +190 -20
  73. modal/dict.py +290 -71
  74. modal/dict.pyi +835 -83
  75. modal/environments.py +15 -27
  76. modal/environments.pyi +46 -24
  77. modal/exception.py +14 -2
  78. modal/experimental/__init__.py +194 -40
  79. modal/experimental/flash.py +618 -0
  80. modal/experimental/flash.pyi +380 -0
  81. modal/experimental/ipython.py +11 -7
  82. modal/file_io.py +29 -36
  83. modal/file_io.pyi +251 -53
  84. modal/file_pattern_matcher.py +56 -16
  85. modal/functions.pyi +673 -92
  86. modal/gpu.py +1 -1
  87. modal/image.py +528 -176
  88. modal/image.pyi +1572 -145
  89. modal/io_streams.py +458 -128
  90. modal/io_streams.pyi +433 -52
  91. modal/mount.py +216 -151
  92. modal/mount.pyi +225 -78
  93. modal/network_file_system.py +45 -62
  94. modal/network_file_system.pyi +277 -56
  95. modal/object.pyi +93 -17
  96. modal/parallel_map.py +942 -129
  97. modal/parallel_map.pyi +294 -15
  98. modal/partial_function.py +0 -2
  99. modal/partial_function.pyi +234 -19
  100. modal/proxy.py +17 -8
  101. modal/proxy.pyi +36 -3
  102. modal/queue.py +270 -65
  103. modal/queue.pyi +817 -57
  104. modal/runner.py +115 -101
  105. modal/runner.pyi +205 -49
  106. modal/sandbox.py +512 -136
  107. modal/sandbox.pyi +845 -111
  108. modal/schedule.py +1 -1
  109. modal/secret.py +300 -70
  110. modal/secret.pyi +589 -34
  111. modal/serving.py +7 -11
  112. modal/serving.pyi +7 -8
  113. modal/snapshot.py +11 -8
  114. modal/snapshot.pyi +25 -4
  115. modal/token_flow.py +4 -4
  116. modal/token_flow.pyi +28 -8
  117. modal/volume.py +416 -158
  118. modal/volume.pyi +1117 -121
  119. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
  120. modal-1.2.3.dev7.dist-info/RECORD +195 -0
  121. modal_docs/mdmd/mdmd.py +17 -4
  122. modal_proto/api.proto +534 -79
  123. modal_proto/api_grpc.py +337 -1
  124. modal_proto/api_pb2.py +1522 -968
  125. modal_proto/api_pb2.pyi +1619 -134
  126. modal_proto/api_pb2_grpc.py +699 -4
  127. modal_proto/api_pb2_grpc.pyi +226 -14
  128. modal_proto/modal_api_grpc.py +175 -154
  129. modal_proto/sandbox_router.proto +145 -0
  130. modal_proto/sandbox_router_grpc.py +105 -0
  131. modal_proto/sandbox_router_pb2.py +149 -0
  132. modal_proto/sandbox_router_pb2.pyi +333 -0
  133. modal_proto/sandbox_router_pb2_grpc.py +203 -0
  134. modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
  135. modal_proto/task_command_router.proto +144 -0
  136. modal_proto/task_command_router_grpc.py +105 -0
  137. modal_proto/task_command_router_pb2.py +149 -0
  138. modal_proto/task_command_router_pb2.pyi +333 -0
  139. modal_proto/task_command_router_pb2_grpc.py +203 -0
  140. modal_proto/task_command_router_pb2_grpc.pyi +75 -0
  141. modal_version/__init__.py +1 -1
  142. modal/requirements/PREVIEW.txt +0 -16
  143. modal/requirements/base-images.json +0 -26
  144. modal-1.0.3.dev10.dist-info/RECORD +0 -179
  145. modal_proto/modal_options_grpc.py +0 -3
  146. modal_proto/options.proto +0 -19
  147. modal_proto/options_grpc.py +0 -3
  148. modal_proto/options_pb2.py +0 -35
  149. modal_proto/options_pb2.pyi +0 -20
  150. modal_proto/options_pb2_grpc.py +0 -4
  151. modal_proto/options_pb2_grpc.pyi +0 -7
  152. /modal/{requirements → builder}/2023.12.312.txt +0 -0
  153. /modal/{requirements → builder}/2023.12.txt +0 -0
  154. /modal/{requirements → builder}/2024.04.txt +0 -0
  155. /modal/{requirements → builder}/2024.10.txt +0 -0
  156. /modal/{requirements → builder}/README.md +0 -0
  157. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
  158. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
  159. {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
  160. {modal-1.0.3.dev10.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,13 +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
 
31
+ import modal.exception
29
32
  import modal_proto.api_pb2
30
- from modal.exception import InvalidError, VolumeUploadTimeoutError
33
+ from modal.exception import AlreadyExistsError, InvalidError, NotFoundError, VolumeUploadTimeoutError
31
34
  from modal_proto import api_pb2
32
35
 
33
- from ._object import EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, _get_environment_name, _Object, live_method, live_method_gen
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
+ )
34
44
  from ._resolver import Resolver
35
45
  from ._utils.async_utils import (
36
46
  TaskContext,
@@ -45,15 +55,15 @@ from ._utils.blob_utils import (
45
55
  BLOCK_SIZE,
46
56
  FileUploadSpec,
47
57
  FileUploadSpec2,
48
- blob_iter,
49
58
  blob_upload_file,
50
59
  get_file_upload_spec_from_fileobj,
51
60
  get_file_upload_spec_from_path,
52
61
  )
53
- from ._utils.deprecation import deprecation_warning
54
- from ._utils.grpc_utils import retry_transient_errors
62
+ from ._utils.deprecation import deprecation_warning, warn_if_passing_namespace
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
 
@@ -136,6 +331,61 @@ class _Volume(_Object, type_prefix="vo"):
136
331
 
137
332
  _lock: Optional[asyncio.Lock] = None
138
333
  _metadata: "typing.Optional[api_pb2.VolumeMetadata]"
334
+ _read_only: bool = False
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
+
344
+ def read_only(self) -> "_Volume":
345
+ """Configure Volume to mount as read-only.
346
+
347
+ **Example**
348
+
349
+ ```python
350
+ import modal
351
+
352
+ volume = modal.Volume.from_name("my-volume", create_if_missing=True)
353
+
354
+ @app.function(volumes={"/mnt/items": volume.read_only()})
355
+ def f():
356
+ with open("/mnt/items/my-file.txt") as f:
357
+ return f.read()
358
+ ```
359
+
360
+ The Volume is mounted as a read-only volume in a function. Any file system write operation into the
361
+ mounted volume will result in an error.
362
+
363
+ Added in v1.0.5.
364
+ """
365
+
366
+ async def _load(
367
+ new_volume: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
368
+ ):
369
+ new_volume._initialize_from_other(self)
370
+ new_volume._read_only = True
371
+
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
+ )
379
+ return obj
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
139
389
 
140
390
  async def _get_lock(self):
141
391
  # To (mostly*) prevent multiple concurrent operations on the same volume, which can cause problems under
@@ -151,20 +401,29 @@ class _Volume(_Object, type_prefix="vo"):
151
401
  self._lock = asyncio.Lock()
152
402
  return self._lock
153
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
+
154
412
  @staticmethod
155
413
  def from_name(
156
414
  name: str,
157
415
  *,
158
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
416
+ namespace=None, # mdmd:line-hidden
159
417
  environment_name: Optional[str] = None,
160
418
  create_if_missing: bool = False,
161
419
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
420
+ client: Optional[_Client] = None,
162
421
  ) -> "_Volume":
163
422
  """Reference a Volume by name, creating if necessary.
164
423
 
165
- In contrast to `modal.Volume.lookup`, this is a lazy method
166
- that defers hydrating the local object with metadata from
167
- Modal servers until the first time is is actually used.
424
+ This is a lazy method that defers hydrating the local
425
+ object with metadata from Modal servers until the first
426
+ time is is actually used.
168
427
 
169
428
  ```python
170
429
  vol = modal.Volume.from_name("my-volume", create_if_missing=True)
@@ -178,36 +437,28 @@ class _Volume(_Object, type_prefix="vo"):
178
437
  ```
179
438
  """
180
439
  check_object_name(name, "Volume")
440
+ warn_if_passing_namespace(namespace, "modal.Volume.from_name")
181
441
 
182
- async def _load(self: _Volume, resolver: Resolver, existing_object_id: Optional[str]):
442
+ async def _load(
443
+ self: _Volume, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str]
444
+ ):
183
445
  req = api_pb2.VolumeGetOrCreateRequest(
184
446
  deployment_name=name,
185
- namespace=namespace,
186
- environment_name=_get_environment_name(environment_name, resolver),
447
+ environment_name=load_context.environment_name,
187
448
  object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
188
449
  version=version,
189
450
  )
190
- response = await resolver.client.stub.VolumeGetOrCreate(req)
191
- self._hydrate(response.volume_id, resolver.client, response.metadata)
192
-
193
- return _Volume._from_loader(_load, "Volume()", hydrate_lazily=True)
194
-
195
- def _hydrate_metadata(self, metadata: Optional[Message]):
196
- if metadata and isinstance(metadata, api_pb2.VolumeMetadata):
197
- self._metadata = metadata
198
- else:
199
- raise TypeError("_hydrate_metadata() requires an `api_pb2.VolumeMetadata` to determine volume version")
200
-
201
- def _get_metadata(self) -> Optional[Message]:
202
- return self._metadata
203
-
204
- @property
205
- def _is_v1(self) -> bool:
206
- return self._metadata.version in [
207
- None,
208
- api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_UNSPECIFIED,
209
- api_pb2.VolumeFsVersion.VOLUME_FS_VERSION_V1,
210
- ]
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
+ )
211
462
 
212
463
  @classmethod
213
464
  @asynccontextmanager
@@ -216,7 +467,7 @@ class _Volume(_Object, type_prefix="vo"):
216
467
  client: Optional[_Client] = None,
217
468
  environment_name: Optional[str] = None,
218
469
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
219
- _heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
470
+ _heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, # mdmd:line-hidden
220
471
  ) -> AsyncGenerator["_Volume", None]:
221
472
  """Creates a new ephemeral volume within a context manager:
222
473
 
@@ -243,80 +494,74 @@ class _Volume(_Object, type_prefix="vo"):
243
494
  async with TaskContext() as tc:
244
495
  request = api_pb2.VolumeHeartbeatRequest(volume_id=response.volume_id)
245
496
  tc.infinite_loop(lambda: client.stub.VolumeHeartbeat(request), sleep=_heartbeat_sleep)
246
- yield cls._new_hydrated(response.volume_id, client, response.metadata, is_another_app=True)
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
+ )
247
504
 
248
505
  @staticmethod
249
- async def lookup(
250
- name: str,
251
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
506
+ async def create_deployed(
507
+ deployment_name: str,
508
+ namespace=None, # mdmd:line-hidden
252
509
  client: Optional[_Client] = None,
253
510
  environment_name: Optional[str] = None,
254
- create_if_missing: bool = False,
255
511
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
256
- ) -> "_Volume":
257
- """mdmd:hidden
258
- Lookup a named Volume.
259
-
260
- DEPRECATED: This method is deprecated in favor of `modal.Volume.from_name`.
261
-
262
- In contrast to `modal.Volume.from_name`, this is an eager method
263
- that will hydrate the local object with metadata from Modal servers.
264
-
265
- ```python notest
266
- vol = modal.Volume.from_name("my-volume")
267
- print(vol.listdir("/"))
268
- ```
269
- """
512
+ ) -> str:
513
+ """mdmd:hidden"""
270
514
  deprecation_warning(
271
- (2025, 1, 27),
272
- "`modal.Volume.lookup` is deprecated and will be removed in a future release."
273
- " It can be replaced with `modal.Volume.from_name`."
274
- "\n\nSee https://modal.com/docs/guide/modal-1-0-migration for more information.",
275
- )
276
- obj = _Volume.from_name(
277
- name,
278
- namespace=namespace,
279
- environment_name=environment_name,
280
- create_if_missing=create_if_missing,
281
- 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`.",
282
518
  )
283
- if client is None:
284
- client = await _Client.from_env()
285
- resolver = Resolver(client=client)
286
- await resolver.load(obj)
287
- return obj
519
+ return await _Volume._create_deployed(deployment_name, namespace, client, environment_name, version)
288
520
 
289
521
  @staticmethod
290
- async def create_deployed(
522
+ async def _create_deployed(
291
523
  deployment_name: str,
292
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
524
+ namespace=None, # mdmd:line-hidden
293
525
  client: Optional[_Client] = None,
294
526
  environment_name: Optional[str] = None,
295
527
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
296
528
  ) -> str:
297
529
  """mdmd:hidden"""
298
530
  check_object_name(deployment_name, "Volume")
531
+ warn_if_passing_namespace(namespace, "modal.Volume.create_deployed")
299
532
  if client is None:
300
533
  client = await _Client.from_env()
301
534
  request = api_pb2.VolumeGetOrCreateRequest(
302
535
  deployment_name=deployment_name,
303
- namespace=namespace,
304
536
  environment_name=_get_environment_name(environment_name),
305
537
  object_creation_type=api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS,
306
538
  version=version,
307
539
  )
308
- resp = await retry_transient_errors(client.stub.VolumeGetOrCreate, request)
540
+ resp = await client.stub.VolumeGetOrCreate(request)
309
541
  return resp.volume_id
310
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
+
311
556
  @live_method
312
557
  async def _do_reload(self, lock=True):
313
558
  async with (await self._get_lock()) if lock else asyncnullcontext():
314
559
  req = api_pb2.VolumeReloadRequest(volume_id=self.object_id)
315
- _ = await retry_transient_errors(self._client.stub.VolumeReload, req)
560
+ _ = await self._client.stub.VolumeReload(req)
316
561
 
317
562
  @live_method
318
563
  async def commit(self):
319
- """Commit changes to the volume.
564
+ """Commit changes to a mounted volume.
320
565
 
321
566
  If successful, the changes made are now persisted in durable storage and available to other containers accessing
322
567
  the volume.
@@ -325,7 +570,7 @@ class _Volume(_Object, type_prefix="vo"):
325
570
  req = api_pb2.VolumeCommitRequest(volume_id=self.object_id)
326
571
  try:
327
572
  # TODO(gongy): only apply indefinite retries on 504 status.
328
- resp = await retry_transient_errors(self._client.stub.VolumeCommit, req, max_retries=90)
573
+ resp = await self._client.stub.VolumeCommit(req, retry=Retry(max_retries=90))
329
574
  if not resp.skip_reload:
330
575
  # Reload changes on successful commit.
331
576
  await self._do_reload(lock=False)
@@ -400,10 +645,14 @@ class _Volume(_Object, type_prefix="vo"):
400
645
  return [entry async for entry in self.iterdir(path, recursive=recursive)]
401
646
 
402
647
  @live_method_gen
403
- def read_file(self, path: str) -> AsyncIterator[bytes]:
648
+ async def read_file(self, path: str) -> AsyncIterator[bytes]:
404
649
  """
405
650
  Read a file from the modal.Volume.
406
651
 
652
+ Note - this function is primarily intended to be used outside of a Modal App.
653
+ For more information on downloading files from a Modal Volume, see
654
+ [the guide](https://modal.com/docs/guide/volumes).
655
+
407
656
  **Example:**
408
657
 
409
658
  ```python notest
@@ -414,32 +663,17 @@ class _Volume(_Object, type_prefix="vo"):
414
663
  print(len(data)) # == 1024 * 1024
415
664
  ```
416
665
  """
417
- return self._read_file1(path) if self._is_v1 else self._read_file2(path)
418
-
419
- async def _read_file1(self, path: str) -> AsyncIterator[bytes]:
420
- req = api_pb2.VolumeGetFileRequest(volume_id=self.object_id, path=path)
421
- try:
422
- response = await retry_transient_errors(self._client.stub.VolumeGetFile, req)
423
- except GRPCError as exc:
424
- raise FileNotFoundError(exc.message) if exc.status == Status.NOT_FOUND else exc
425
- # TODO(Jonathon): use ranged requests.
426
- if response.WhichOneof("data_oneof") == "data":
427
- yield response.data
428
- return
429
- else:
430
- async for data in blob_iter(response.data_blob_id, self._client.stub):
431
- yield data
432
-
433
- async def _read_file2(self, path: str) -> AsyncIterator[bytes]:
434
666
  req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
435
667
 
436
668
  try:
437
- response = await retry_transient_errors(self._client.stub.VolumeGetFile2, req)
438
- except GRPCError as exc:
439
- raise FileNotFoundError(exc.message) if exc.status == Status.NOT_FOUND else exc
669
+ response = await self._client.stub.VolumeGetFile2(req)
670
+ except modal.exception.NotFoundError as exc:
671
+ raise FileNotFoundError(exc.args[0])
440
672
 
673
+ @retry(n_attempts=5, base_delay=0.1, timeout=None)
441
674
  async def read_block(block_url: str) -> bytes:
442
675
  async with ClientSessionRegistry.get_session().get(block_url) as get_response:
676
+ get_response.raise_for_status()
443
677
  return await get_response.content.read()
444
678
 
445
679
  async def iter_urls() -> AsyncGenerator[str]:
@@ -455,59 +689,53 @@ class _Volume(_Object, type_prefix="vo"):
455
689
 
456
690
  @live_method
457
691
  async def read_file_into_fileobj(
458
- self, path: str, fileobj: typing.IO[bytes], progress_cb: Optional[Callable[..., Any]] = None
692
+ self,
693
+ path: str,
694
+ fileobj: typing.IO[bytes],
695
+ progress_cb: Optional[Callable[..., Any]] = None,
459
696
  ) -> int:
460
697
  """mdmd:hidden
461
698
  Read volume file into file-like IO object.
462
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:
463
711
  if progress_cb is None:
464
712
 
465
713
  def progress_cb(*_, **__):
466
714
  pass
467
715
 
468
- if self._is_v1:
469
- return await self._read_file_into_fileobj1(path, fileobj, progress_cb)
470
- else:
471
- return await self._read_file_into_fileobj2(path, fileobj, progress_cb)
472
-
473
- async def _read_file_into_fileobj1(
474
- self, path: str, fileobj: typing.IO[bytes], progress_cb: Callable[..., Any]
475
- ) -> int:
476
- num_bytes_written = 0
477
-
478
- async for chunk in self._read_file1(path):
479
- num_chunk_bytes_written = 0
480
-
481
- while num_chunk_bytes_written < len(chunk):
482
- # TODO(dflemstr): this is a small write, but nonetheless might block the event loop for some time:
483
- n = fileobj.write(chunk)
484
- num_chunk_bytes_written += n
485
- progress_cb(advance=n)
486
-
487
- num_bytes_written += len(chunk)
716
+ if concurrency is None:
717
+ concurrency = multiprocessing.cpu_count()
488
718
 
489
- return num_bytes_written
490
-
491
- async def _read_file_into_fileobj2(
492
- self, path: str, fileobj: typing.IO[bytes], progress_cb: Callable[..., Any]
493
- ) -> int:
494
719
  req = api_pb2.VolumeGetFile2Request(volume_id=self.object_id, path=path)
495
720
 
496
721
  try:
497
- response = await retry_transient_errors(self._client.stub.VolumeGetFile2, req)
498
- except GRPCError as exc:
499
- raise FileNotFoundError(exc.message) if exc.status == Status.NOT_FOUND else exc
722
+ response = await self._client.stub.VolumeGetFile2(req)
723
+ except modal.exception.NotFoundError as exc:
724
+ raise FileNotFoundError(exc.args[0])
725
+
726
+ if download_semaphore is None:
727
+ download_semaphore = asyncio.Semaphore(concurrency)
500
728
 
501
- # TODO(dflemstr): Sane default limit? Make configurable?
502
- download_semaphore = asyncio.Semaphore(multiprocessing.cpu_count())
503
729
  write_lock = asyncio.Lock()
504
730
  start_pos = fileobj.tell()
505
731
 
732
+ @retry(n_attempts=5, base_delay=0.1, timeout=None)
506
733
  async def download_block(idx, url) -> int:
507
734
  block_start_pos = start_pos + idx * BLOCK_SIZE
508
735
  num_bytes_written = 0
509
736
 
510
737
  async with download_semaphore, ClientSessionRegistry.get_session().get(url) as get_response:
738
+ get_response.raise_for_status()
511
739
  async for chunk in get_response.content.iter_any():
512
740
  num_chunk_bytes_written = 0
513
741
 
@@ -535,15 +763,21 @@ class _Volume(_Object, type_prefix="vo"):
535
763
  @live_method
536
764
  async def remove_file(self, path: str, recursive: bool = False) -> None:
537
765
  """Remove a file or directory from a volume."""
538
- if self._is_v1:
539
- req = api_pb2.VolumeRemoveFileRequest(volume_id=self.object_id, path=path, recursive=recursive)
540
- await retry_transient_errors(self._client.stub.VolumeRemoveFile, req)
541
- else:
542
- req = api_pb2.VolumeRemoveFile2Request(volume_id=self.object_id, path=path, recursive=recursive)
543
- await retry_transient_errors(self._client.stub.VolumeRemoveFile2, req)
766
+ if self._read_only:
767
+ raise InvalidError("Read-only Volume can not be written to")
768
+
769
+ try:
770
+ if self._is_v1:
771
+ req = api_pb2.VolumeRemoveFileRequest(volume_id=self.object_id, path=path, recursive=recursive)
772
+ await self._client.stub.VolumeRemoveFile(req)
773
+ else:
774
+ req = api_pb2.VolumeRemoveFile2Request(volume_id=self.object_id, path=path, recursive=recursive)
775
+ await self._client.stub.VolumeRemoveFile2(req)
776
+ except modal.exception.NotFoundError as exc:
777
+ raise FileNotFoundError(exc.args[0])
544
778
 
545
779
  @live_method
546
- async def copy_files(self, src_paths: Sequence[str], dst_path: str) -> None:
780
+ async def copy_files(self, src_paths: Sequence[str], dst_path: str, recursive: bool = False) -> None:
547
781
  """
548
782
  Copy files within the volume from src_paths to dst_path.
549
783
  The semantics of the copy operation follow those of the UNIX cp command.
@@ -567,8 +801,22 @@ class _Volume(_Object, type_prefix="vo"):
567
801
  like `os.rename()` and then `commit()` the volume. The `copy_files()` method is useful when you don't have
568
802
  the volume mounted as a filesystem, e.g. when running a script on your local computer.
569
803
  """
570
- request = api_pb2.VolumeCopyFilesRequest(volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path)
571
- await retry_transient_errors(self._client.stub.VolumeCopyFiles, request, base_delay=1)
804
+ if self._read_only:
805
+ raise InvalidError("Read-only Volume can not be written to")
806
+
807
+ if self._is_v1:
808
+ if recursive:
809
+ raise ValueError("`recursive` is not supported for V1 volumes")
810
+
811
+ request = api_pb2.VolumeCopyFilesRequest(
812
+ volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path, recursive=recursive
813
+ )
814
+ await self._client.stub.VolumeCopyFiles(request, retry=Retry(base_delay=1))
815
+ else:
816
+ request = api_pb2.VolumeCopyFiles2Request(
817
+ volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path, recursive=recursive
818
+ )
819
+ await self._client.stub.VolumeCopyFiles2(request, retry=Retry(base_delay=1))
572
820
 
573
821
  @live_method
574
822
  async def batch_upload(self, force: bool = False) -> "_AbstractVolumeUploadContextManager":
@@ -589,21 +837,33 @@ class _Volume(_Object, type_prefix="vo"):
589
837
  batch.put_file(io.BytesIO(b"some data"), "/foobar")
590
838
  ```
591
839
  """
840
+ if self._read_only:
841
+ raise InvalidError("Read-only Volume can not be written to")
842
+
592
843
  return _AbstractVolumeUploadContextManager.resolve(
593
844
  self._metadata.version, self.object_id, self._client, force=force
594
845
  )
595
846
 
596
847
  @live_method
597
848
  async def _instance_delete(self):
598
- await retry_transient_errors(
599
- self._client.stub.VolumeDelete, api_pb2.VolumeDeleteRequest(volume_id=self.object_id)
600
- )
849
+ await self._client.stub.VolumeDelete(api_pb2.VolumeDeleteRequest(volume_id=self.object_id))
601
850
 
602
851
  @staticmethod
603
852
  async def delete(name: str, client: Optional[_Client] = None, environment_name: Optional[str] = None):
604
- obj = await _Volume.from_name(name, environment_name=environment_name).hydrate(client)
605
- req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
606
- await retry_transient_errors(obj._client.stub.VolumeDelete, req)
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)
607
867
 
608
868
  @staticmethod
609
869
  async def rename(
@@ -615,7 +875,7 @@ class _Volume(_Object, type_prefix="vo"):
615
875
  ):
616
876
  obj = await _Volume.from_name(old_name, environment_name=environment_name).hydrate(client)
617
877
  req = api_pb2.VolumeRenameRequest(volume_id=obj.object_id, name=new_name)
618
- await retry_transient_errors(obj._client.stub.VolumeRename, req)
878
+ await obj._client.stub.VolumeRename(req)
619
879
 
620
880
 
621
881
  Volume = synchronize_api(_Volume)
@@ -716,7 +976,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
716
976
  disallow_overwrite_existing_files=not self._force,
717
977
  )
718
978
  try:
719
- await retry_transient_errors(self._client.stub.VolumePutFiles, request, base_delay=1)
979
+ await self._client.stub.VolumePutFiles(request, retry=Retry(base_delay=1))
720
980
  except GRPCError as exc:
721
981
  raise FileExistsError(exc.message) if exc.status == Status.ALREADY_EXISTS else exc
722
982
 
@@ -776,7 +1036,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
776
1036
  remote_filename = file_spec.mount_filename
777
1037
  progress_task_id = self._progress_cb(name=remote_filename, size=file_spec.size)
778
1038
  request = api_pb2.MountPutFileRequest(sha256_hex=file_spec.sha256_hex)
779
- response = await retry_transient_errors(self._client.stub.MountPutFile, request, base_delay=1)
1039
+ response = await self._client.stub.MountPutFile(request, retry=Retry(base_delay=1))
780
1040
 
781
1041
  start_time = time.monotonic()
782
1042
  if not response.exists:
@@ -800,7 +1060,7 @@ class _VolumeUploadContextManager(_AbstractVolumeUploadContextManager):
800
1060
  self._progress_cb(task_id=progress_task_id, complete=True)
801
1061
 
802
1062
  while (time.monotonic() - start_time) < VOLUME_PUT_FILE_CLIENT_TIMEOUT:
803
- response = await retry_transient_errors(self._client.stub.MountPutFile, request2, base_delay=1)
1063
+ response = await self._client.stub.MountPutFile(request2, retry=Retry(base_delay=1))
804
1064
  if response.exists:
805
1065
  break
806
1066
 
@@ -943,9 +1203,9 @@ class _VolumeUploadContextManager2(_AbstractVolumeUploadContextManager):
943
1203
  for file_spec in file_specs:
944
1204
  blocks = [
945
1205
  api_pb2.VolumePutFiles2Request.Block(
946
- contents_sha256=block_sha256, put_response=put_responses.get(block_sha256)
1206
+ contents_sha256=block.contents_sha256, put_response=put_responses.get(block.contents_sha256)
947
1207
  )
948
- for block_sha256 in file_spec.blocks_sha256
1208
+ for block in file_spec.blocks
949
1209
  ]
950
1210
  files.append(
951
1211
  api_pb2.VolumePutFiles2Request.File(
@@ -960,7 +1220,7 @@ class _VolumeUploadContextManager2(_AbstractVolumeUploadContextManager):
960
1220
  )
961
1221
 
962
1222
  try:
963
- response = await retry_transient_errors(self._client.stub.VolumePutFiles2, request, base_delay=1)
1223
+ response = await self._client.stub.VolumePutFiles2(request, retry=Retry(base_delay=1))
964
1224
  except GRPCError as exc:
965
1225
  raise FileExistsError(exc.message) if exc.status == Status.ALREADY_EXISTS else exc
966
1226
 
@@ -1002,7 +1262,7 @@ async def _put_missing_blocks(
1002
1262
  # TODO(dflemstr): Type is `api_pb2.VolumePutFiles2Response.MissingBlock` but synchronicity gets confused
1003
1263
  # by the nested class (?)
1004
1264
  missing_block,
1005
- ) -> (bytes, bytes):
1265
+ ) -> tuple[bytes, bytes]:
1006
1266
  # Lazily import to keep the eager loading time of this module down
1007
1267
  from ._utils.bytes_io_segment_payload import BytesIOSegmentPayload
1008
1268
 
@@ -1011,9 +1271,7 @@ async def _put_missing_blocks(
1011
1271
  file_spec = file_specs[missing_block.file_index]
1012
1272
  # TODO(dflemstr): What if the underlying file has changed here in the meantime; should we check the
1013
1273
  # hash again just to be sure?
1014
- block_sha256 = file_spec.blocks_sha256[missing_block.block_index]
1015
- block_start = missing_block.block_index * BLOCK_SIZE
1016
- block_length = min(BLOCK_SIZE, file_spec.size - block_start)
1274
+ block = file_spec.blocks[missing_block.block_index]
1017
1275
 
1018
1276
  if file_spec.path not in file_progresses:
1019
1277
  file_task_id = progress_cb(name=file_spec.path, size=file_spec.size)
@@ -1023,7 +1281,7 @@ async def _put_missing_blocks(
1023
1281
  file_progress.pending_blocks.add(missing_block.block_index)
1024
1282
  task_progress_cb = functools.partial(progress_cb, task_id=file_progress.task_id)
1025
1283
 
1026
- @retry(n_attempts=5, base_delay=0.5, timeout=None)
1284
+ @retry(n_attempts=11, base_delay=0.5, timeout=None)
1027
1285
  async def put_missing_block_attempt(payload: BytesIOSegmentPayload) -> bytes:
1028
1286
  with payload.reset_on_error(subtract_progress=True):
1029
1287
  async with ClientSessionRegistry.get_session().put(
@@ -1037,8 +1295,8 @@ async def _put_missing_blocks(
1037
1295
  with file_spec.source() as source_fp:
1038
1296
  payload = BytesIOSegmentPayload(
1039
1297
  source_fp,
1040
- block_start,
1041
- block_length,
1298
+ block.start,
1299
+ block.end - block.start,
1042
1300
  # limit chunk size somewhat to not keep event loop busy for too long
1043
1301
  chunk_size=256 * 1024,
1044
1302
  progress_report_cb=task_progress_cb,
@@ -1050,7 +1308,7 @@ async def _put_missing_blocks(
1050
1308
  if len(file_progress.pending_blocks) == 0:
1051
1309
  task_progress_cb(complete=True)
1052
1310
 
1053
- return block_sha256, resp_data
1311
+ return block.contents_sha256, resp_data
1054
1312
 
1055
1313
  tasks = [asyncio.create_task(put_missing_block(missing_block)) for missing_block in missing_blocks]
1056
1314
  for task_result in asyncio.as_completed(tasks):