modal 1.0.6.dev58__py3-none-any.whl → 1.2.3.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of modal might be problematic. Click here for more details.

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