modal 1.1.1.dev41__py3-none-any.whl → 1.1.2__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 (68) hide show
  1. modal/__main__.py +1 -2
  2. modal/_container_entrypoint.py +18 -7
  3. modal/_functions.py +135 -13
  4. modal/_object.py +13 -2
  5. modal/_partial_function.py +8 -8
  6. modal/_runtime/asgi.py +3 -2
  7. modal/_runtime/container_io_manager.py +20 -14
  8. modal/_runtime/container_io_manager.pyi +38 -13
  9. modal/_runtime/execution_context.py +18 -2
  10. modal/_runtime/execution_context.pyi +4 -1
  11. modal/_runtime/gpu_memory_snapshot.py +158 -54
  12. modal/_utils/blob_utils.py +83 -24
  13. modal/_utils/function_utils.py +4 -3
  14. modal/_utils/time_utils.py +28 -4
  15. modal/app.py +8 -4
  16. modal/app.pyi +8 -8
  17. modal/cli/dict.py +14 -11
  18. modal/cli/entry_point.py +9 -3
  19. modal/cli/launch.py +102 -4
  20. modal/cli/profile.py +1 -0
  21. modal/cli/programs/launch_instance_ssh.py +94 -0
  22. modal/cli/programs/run_marimo.py +95 -0
  23. modal/cli/queues.py +49 -19
  24. modal/cli/secret.py +45 -18
  25. modal/cli/volume.py +14 -16
  26. modal/client.pyi +2 -10
  27. modal/cls.py +12 -2
  28. modal/cls.pyi +9 -1
  29. modal/config.py +7 -7
  30. modal/dict.py +206 -12
  31. modal/dict.pyi +358 -4
  32. modal/experimental/__init__.py +130 -0
  33. modal/file_io.py +1 -1
  34. modal/file_io.pyi +2 -2
  35. modal/file_pattern_matcher.py +25 -16
  36. modal/functions.pyi +111 -11
  37. modal/image.py +9 -3
  38. modal/image.pyi +7 -7
  39. modal/mount.py +20 -13
  40. modal/mount.pyi +16 -3
  41. modal/network_file_system.py +8 -2
  42. modal/object.pyi +3 -0
  43. modal/parallel_map.py +346 -101
  44. modal/parallel_map.pyi +108 -0
  45. modal/proxy.py +2 -1
  46. modal/queue.py +199 -9
  47. modal/queue.pyi +357 -3
  48. modal/sandbox.py +6 -5
  49. modal/sandbox.pyi +17 -14
  50. modal/secret.py +196 -3
  51. modal/secret.pyi +372 -0
  52. modal/volume.py +239 -23
  53. modal/volume.pyi +405 -10
  54. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/METADATA +2 -2
  55. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/RECORD +68 -66
  56. modal_docs/mdmd/mdmd.py +11 -1
  57. modal_proto/api.proto +37 -10
  58. modal_proto/api_grpc.py +32 -0
  59. modal_proto/api_pb2.py +627 -597
  60. modal_proto/api_pb2.pyi +107 -19
  61. modal_proto/api_pb2_grpc.py +67 -2
  62. modal_proto/api_pb2_grpc.pyi +24 -8
  63. modal_proto/modal_api_grpc.py +2 -0
  64. modal_version/__init__.py +1 -1
  65. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/WHEEL +0 -0
  66. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/entry_points.txt +0 -0
  67. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/licenses/LICENSE +0 -0
  68. {modal-1.1.1.dev41.dist-info → modal-1.1.2.dist-info}/top_level.txt +0 -0
modal/volume.py CHANGED
@@ -25,14 +25,21 @@ from typing import (
25
25
 
26
26
  from google.protobuf.message import Message
27
27
  from grpclib import GRPCError, Status
28
+ from synchronicity import classproperty
28
29
  from synchronicity.async_wrap import asynccontextmanager
29
30
 
30
31
  import modal.exception
31
32
  import modal_proto.api_pb2
32
- from modal.exception import InvalidError, VolumeUploadTimeoutError
33
+ from modal.exception import AlreadyExistsError, InvalidError, NotFoundError, VolumeUploadTimeoutError
33
34
  from modal_proto import api_pb2
34
35
 
35
- from ._object import EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, _get_environment_name, _Object, live_method, live_method_gen
36
+ from ._object import (
37
+ EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
38
+ _get_environment_name,
39
+ _Object,
40
+ live_method,
41
+ live_method_gen,
42
+ )
36
43
  from ._resolver import Resolver
37
44
  from ._utils.async_utils import (
38
45
  TaskContext,
@@ -55,7 +62,7 @@ from ._utils.deprecation import deprecation_warning, warn_if_passing_namespace
55
62
  from ._utils.grpc_utils import retry_transient_errors
56
63
  from ._utils.http_utils import ClientSessionRegistry
57
64
  from ._utils.name_utils import check_object_name
58
- from ._utils.time_utils import timestamp_to_localized_dt
65
+ from ._utils.time_utils import as_timestamp, timestamp_to_localized_dt
59
66
  from .client import _Client
60
67
  from .config import logger
61
68
 
@@ -106,6 +113,179 @@ class VolumeInfo:
106
113
  created_by: Optional[str]
107
114
 
108
115
 
116
+ class _VolumeManager:
117
+ """Namespace with methods for managing named Volume objects."""
118
+
119
+ @staticmethod
120
+ async def create(
121
+ name: str, # Name to use for the new Volume
122
+ *,
123
+ version: Optional[int] = None, # Experimental: Configure the backend VolumeFS version
124
+ allow_existing: bool = False, # If True, no-op when the Volume already exists
125
+ environment_name: Optional[str] = None, # Uses active environment if not specified
126
+ client: Optional[_Client] = None, # Optional client with Modal credentials
127
+ ) -> None:
128
+ """Create a new Volume object.
129
+
130
+ **Examples:**
131
+
132
+ ```python notest
133
+ modal.Volume.objects.create("my-volume")
134
+ ```
135
+
136
+ Volumes will be created in the active environment, or another one can be specified:
137
+
138
+ ```python notest
139
+ modal.Volume.objects.create("my-volume", environment_name="dev")
140
+ ```
141
+
142
+ By default, an error will be raised if the Volume already exists, but passing
143
+ `allow_existing=True` will make the creation attempt a no-op in this case.
144
+
145
+ ```python notest
146
+ modal.Volume.objects.create("my-volume", allow_existing=True)
147
+ ```
148
+
149
+ Note that this method does not return a local instance of the Volume. You can use
150
+ `modal.Volume.from_name` to perform a lookup after creation.
151
+
152
+ Added in v1.1.2.
153
+
154
+ """
155
+ check_object_name(name, "Volume")
156
+ client = await _Client.from_env() if client is None else client
157
+ object_creation_type = (
158
+ api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING
159
+ if allow_existing
160
+ else api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS
161
+ )
162
+
163
+ if version is not None and version not in {1, 2}:
164
+ raise InvalidError("VolumeFS version must be either 1 or 2")
165
+
166
+ req = api_pb2.VolumeGetOrCreateRequest(
167
+ deployment_name=name,
168
+ environment_name=_get_environment_name(environment_name),
169
+ object_creation_type=object_creation_type,
170
+ version=version,
171
+ )
172
+ try:
173
+ await retry_transient_errors(client.stub.VolumeGetOrCreate, req)
174
+ except GRPCError as exc:
175
+ if exc.status == Status.ALREADY_EXISTS and not allow_existing:
176
+ raise AlreadyExistsError(exc.message)
177
+ else:
178
+ raise
179
+
180
+ @staticmethod
181
+ async def list(
182
+ *,
183
+ max_objects: Optional[int] = None, # Limit requests to this size
184
+ created_before: Optional[Union[datetime, str]] = None, # Limit based on creation date
185
+ environment_name: str = "", # Uses active environment if not specified
186
+ client: Optional[_Client] = None, # Optional client with Modal credentials
187
+ ) -> list["_Volume"]:
188
+ """Return a list of hydrated Volume objects.
189
+
190
+ **Examples:**
191
+
192
+ ```python
193
+ volumes = modal.Volume.objects.list()
194
+ print([v.name for v in volumes])
195
+ ```
196
+
197
+ Volumes will be retreived from the active environment, or another one can be specified:
198
+
199
+ ```python notest
200
+ dev_volumes = modal.Volume.objects.list(environment_name="dev")
201
+ ```
202
+
203
+ By default, all named Volumes are returned, newest to oldest. It's also possible to limit the
204
+ number of results and to filter by creation date:
205
+
206
+ ```python
207
+ volumes = modal.Volume.objects.list(max_objects=10, created_before="2025-01-01")
208
+ ```
209
+
210
+ Added in v1.1.2.
211
+
212
+ """
213
+ client = await _Client.from_env() if client is None else client
214
+ if max_objects is not None and max_objects < 0:
215
+ raise InvalidError("max_objects cannot be negative")
216
+
217
+ items: list[api_pb2.VolumeListItem] = []
218
+
219
+ async def retrieve_page(created_before: float) -> bool:
220
+ max_page_size = 100 if max_objects is None else min(100, max_objects - len(items))
221
+ pagination = api_pb2.ListPagination(max_objects=max_page_size, created_before=created_before)
222
+ req = api_pb2.VolumeListRequest(
223
+ environment_name=_get_environment_name(environment_name), pagination=pagination
224
+ )
225
+ resp = await retry_transient_errors(client.stub.VolumeList, req)
226
+ items.extend(resp.items)
227
+ finished = (len(resp.items) < max_page_size) or (max_objects is not None and len(items) >= max_objects)
228
+ return finished
229
+
230
+ finished = await retrieve_page(as_timestamp(created_before))
231
+ while True:
232
+ if finished:
233
+ break
234
+ finished = await retrieve_page(items[-1].metadata.creation_info.created_at)
235
+
236
+ volumes = [
237
+ _Volume._new_hydrated(
238
+ item.volume_id,
239
+ client,
240
+ item.metadata,
241
+ is_another_app=True,
242
+ rep=_Volume._repr(item.label, environment_name),
243
+ )
244
+ for item in items
245
+ ]
246
+ return volumes[:max_objects] if max_objects is not None else volumes
247
+
248
+ @staticmethod
249
+ async def delete(
250
+ name: str, # Name of the Volume to delete
251
+ *,
252
+ allow_missing: bool = False, # If True, don't raise an error if the Volume doesn't exist
253
+ environment_name: Optional[str] = None, # Uses active environment if not specified
254
+ client: Optional[_Client] = None, # Optional client with Modal credentials
255
+ ):
256
+ """Delete a named Volume.
257
+
258
+ Warning: This deletes an *entire Volume*, not just a specific file.
259
+ Deletion is irreversible and will affect any Apps currently using the Volume.
260
+
261
+ **Examples:**
262
+
263
+ ```python notest
264
+ await modal.Volume.objects.delete("my-volume")
265
+ ```
266
+
267
+ Volumes will be deleted from the active environment, or another one can be specified:
268
+
269
+ ```python notest
270
+ await modal.Volume.objects.delete("my-volume", environment_name="dev")
271
+ ```
272
+
273
+ Added in v1.1.2.
274
+
275
+ """
276
+ try:
277
+ obj = await _Volume.from_name(name, environment_name=environment_name).hydrate(client)
278
+ except NotFoundError:
279
+ if not allow_missing:
280
+ raise
281
+ else:
282
+ req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
283
+ await retry_transient_errors(obj._client.stub.VolumeDelete, req)
284
+
285
+
286
+ VolumeManager = synchronize_api(_VolumeManager)
287
+
288
+
109
289
  class _Volume(_Object, type_prefix="vo"):
110
290
  """A writeable volume that can be used to share files between one or more Modal functions.
111
291
 
@@ -152,6 +332,14 @@ class _Volume(_Object, type_prefix="vo"):
152
332
  _metadata: "typing.Optional[api_pb2.VolumeMetadata]"
153
333
  _read_only: bool = False
154
334
 
335
+ @classproperty
336
+ def objects(cls) -> _VolumeManager:
337
+ return _VolumeManager
338
+
339
+ @property
340
+ def name(self) -> Optional[str]:
341
+ return self._name
342
+
155
343
  def read_only(self) -> "_Volume":
156
344
  """Configure Volume to mount as read-only.
157
345
 
@@ -181,10 +369,6 @@ class _Volume(_Object, type_prefix="vo"):
181
369
  obj = _Volume._from_loader(_load, "Volume()", hydrate_lazily=True, deps=lambda: [self])
182
370
  return obj
183
371
 
184
- @property
185
- def name(self) -> Optional[str]:
186
- return self._name
187
-
188
372
  def _hydrate_metadata(self, metadata: Optional[Message]):
189
373
  if metadata:
190
374
  assert isinstance(metadata, api_pb2.VolumeMetadata)
@@ -255,7 +439,8 @@ class _Volume(_Object, type_prefix="vo"):
255
439
  response = await resolver.client.stub.VolumeGetOrCreate(req)
256
440
  self._hydrate(response.volume_id, resolver.client, response.metadata)
257
441
 
258
- return _Volume._from_loader(_load, "Volume()", hydrate_lazily=True, name=name)
442
+ rep = _Volume._repr(name, environment_name)
443
+ return _Volume._from_loader(_load, rep, hydrate_lazily=True, name=name)
259
444
 
260
445
  @classmethod
261
446
  @asynccontextmanager
@@ -264,7 +449,7 @@ class _Volume(_Object, type_prefix="vo"):
264
449
  client: Optional[_Client] = None,
265
450
  environment_name: Optional[str] = None,
266
451
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
267
- _heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
452
+ _heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, # mdmd:line-hidden
268
453
  ) -> AsyncGenerator["_Volume", None]:
269
454
  """Creates a new ephemeral volume within a context manager:
270
455
 
@@ -291,7 +476,13 @@ class _Volume(_Object, type_prefix="vo"):
291
476
  async with TaskContext() as tc:
292
477
  request = api_pb2.VolumeHeartbeatRequest(volume_id=response.volume_id)
293
478
  tc.infinite_loop(lambda: client.stub.VolumeHeartbeat(request), sleep=_heartbeat_sleep)
294
- yield cls._new_hydrated(response.volume_id, client, response.metadata, is_another_app=True)
479
+ yield cls._new_hydrated(
480
+ response.volume_id,
481
+ client,
482
+ response.metadata,
483
+ is_another_app=True,
484
+ rep="modal.Volume.ephemeral()",
485
+ )
295
486
 
296
487
  @staticmethod
297
488
  async def lookup(
@@ -341,6 +532,22 @@ class _Volume(_Object, type_prefix="vo"):
341
532
  client: Optional[_Client] = None,
342
533
  environment_name: Optional[str] = None,
343
534
  version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
535
+ ) -> str:
536
+ """mdmd:hidden"""
537
+ deprecation_warning(
538
+ (2025, 8, 13),
539
+ "The undocumented `modal.Volume.create_deployed` method is deprecated and will be removed "
540
+ "in a future release. It can be replaced with `modal.Volume.objects.create`.",
541
+ )
542
+ return await _Volume._create_deployed(deployment_name, namespace, client, environment_name, version)
543
+
544
+ @staticmethod
545
+ async def _create_deployed(
546
+ deployment_name: str,
547
+ namespace=None, # mdmd:line-hidden
548
+ client: Optional[_Client] = None,
549
+ environment_name: Optional[str] = None,
550
+ version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
344
551
  ) -> str:
345
552
  """mdmd:hidden"""
346
553
  check_object_name(deployment_name, "Volume")
@@ -377,7 +584,7 @@ class _Volume(_Object, type_prefix="vo"):
377
584
 
378
585
  @live_method
379
586
  async def commit(self):
380
- """Commit changes to the volume.
587
+ """Commit changes to a mounted volume.
381
588
 
382
589
  If successful, the changes made are now persisted in durable storage and available to other containers accessing
383
590
  the volume.
@@ -646,9 +853,20 @@ class _Volume(_Object, type_prefix="vo"):
646
853
 
647
854
  @staticmethod
648
855
  async def delete(name: str, client: Optional[_Client] = None, environment_name: Optional[str] = None):
649
- obj = await _Volume.from_name(name, environment_name=environment_name).hydrate(client)
650
- req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
651
- await retry_transient_errors(obj._client.stub.VolumeDelete, req)
856
+ """mdmd:hidden
857
+ Delete a named Volume.
858
+
859
+ Warning: This deletes an *entire Volume*, not just a specific file.
860
+ Deletion is irreversible and will affect any Apps currently using the Volume.
861
+
862
+ DEPRECATED: This method is deprecated; we recommend using `modal.Volume.objects.delete` instead.
863
+
864
+ """
865
+ deprecation_warning(
866
+ (2025, 8, 6),
867
+ "`modal.Volume.delete` is deprecated; we recommend using `modal.Volume.objects.delete` instead.",
868
+ )
869
+ await _Volume.objects.delete(name, environment_name=environment_name, client=client)
652
870
 
653
871
  @staticmethod
654
872
  async def rename(
@@ -988,9 +1206,9 @@ class _VolumeUploadContextManager2(_AbstractVolumeUploadContextManager):
988
1206
  for file_spec in file_specs:
989
1207
  blocks = [
990
1208
  api_pb2.VolumePutFiles2Request.Block(
991
- contents_sha256=block_sha256, put_response=put_responses.get(block_sha256)
1209
+ contents_sha256=block.contents_sha256, put_response=put_responses.get(block.contents_sha256)
992
1210
  )
993
- for block_sha256 in file_spec.blocks_sha256
1211
+ for block in file_spec.blocks
994
1212
  ]
995
1213
  files.append(
996
1214
  api_pb2.VolumePutFiles2Request.File(
@@ -1047,7 +1265,7 @@ async def _put_missing_blocks(
1047
1265
  # TODO(dflemstr): Type is `api_pb2.VolumePutFiles2Response.MissingBlock` but synchronicity gets confused
1048
1266
  # by the nested class (?)
1049
1267
  missing_block,
1050
- ) -> (bytes, bytes):
1268
+ ) -> tuple[bytes, bytes]:
1051
1269
  # Lazily import to keep the eager loading time of this module down
1052
1270
  from ._utils.bytes_io_segment_payload import BytesIOSegmentPayload
1053
1271
 
@@ -1056,9 +1274,7 @@ async def _put_missing_blocks(
1056
1274
  file_spec = file_specs[missing_block.file_index]
1057
1275
  # TODO(dflemstr): What if the underlying file has changed here in the meantime; should we check the
1058
1276
  # hash again just to be sure?
1059
- block_sha256 = file_spec.blocks_sha256[missing_block.block_index]
1060
- block_start = missing_block.block_index * BLOCK_SIZE
1061
- block_length = min(BLOCK_SIZE, file_spec.size - block_start)
1277
+ block = file_spec.blocks[missing_block.block_index]
1062
1278
 
1063
1279
  if file_spec.path not in file_progresses:
1064
1280
  file_task_id = progress_cb(name=file_spec.path, size=file_spec.size)
@@ -1082,8 +1298,8 @@ async def _put_missing_blocks(
1082
1298
  with file_spec.source() as source_fp:
1083
1299
  payload = BytesIOSegmentPayload(
1084
1300
  source_fp,
1085
- block_start,
1086
- block_length,
1301
+ block.start,
1302
+ block.end - block.start,
1087
1303
  # limit chunk size somewhat to not keep event loop busy for too long
1088
1304
  chunk_size=256 * 1024,
1089
1305
  progress_report_cb=task_progress_cb,
@@ -1095,7 +1311,7 @@ async def _put_missing_blocks(
1095
1311
  if len(file_progress.pending_blocks) == 0:
1096
1312
  task_progress_cb(complete=True)
1097
1313
 
1098
- return block_sha256, resp_data
1314
+ return block.contents_sha256, resp_data
1099
1315
 
1100
1316
  tasks = [asyncio.create_task(put_missing_block(missing_block)) for missing_block in missing_blocks]
1101
1317
  for task_result in asyncio.as_completed(tasks):