modal 0.62.115__py3-none-any.whl → 0.72.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. modal/__init__.py +13 -9
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +407 -398
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -60
  11. modal/_resources.py +26 -7
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/{execution_context.py → _runtime/execution_context.py} +11 -2
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +123 -6
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +50 -14
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +386 -104
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +299 -98
  29. modal/_utils/grpc_testing.py +47 -34
  30. modal/_utils/grpc_utils.py +54 -21
  31. modal/_utils/hash_utils.py +51 -10
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +3 -3
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +12 -10
  43. modal/app.py +561 -323
  44. modal/app.pyi +474 -262
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +22 -6
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +203 -42
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +61 -13
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +21 -48
  55. modal/cli/launch.py +28 -14
  56. modal/cli/network_file_system.py +57 -21
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +34 -9
  59. modal/cli/programs/vscode.py +58 -8
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +199 -96
  62. modal/cli/secret.py +5 -4
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +74 -8
  65. modal/cli/volume.py +97 -56
  66. modal/client.py +248 -144
  67. modal/client.pyi +156 -124
  68. modal/cloud_bucket_mount.py +43 -30
  69. modal/cloud_bucket_mount.pyi +32 -25
  70. modal/cls.py +528 -141
  71. modal/cls.pyi +189 -145
  72. modal/config.py +32 -15
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +50 -54
  76. modal/dict.pyi +120 -164
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +30 -43
  80. modal/experimental.py +62 -2
  81. modal/file_io.py +537 -0
  82. modal/file_io.pyi +235 -0
  83. modal/file_pattern_matcher.py +197 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +946 -417
  88. modal/image.pyi +584 -245
  89. modal/io_streams.py +434 -0
  90. modal/io_streams.pyi +122 -0
  91. modal/mount.py +223 -90
  92. modal/mount.pyi +241 -243
  93. modal/network_file_system.py +85 -86
  94. modal/network_file_system.pyi +151 -110
  95. modal/object.py +66 -36
  96. modal/object.pyi +166 -143
  97. modal/output.py +63 -0
  98. modal/parallel_map.py +73 -47
  99. modal/parallel_map.pyi +51 -63
  100. modal/partial_function.py +272 -107
  101. modal/partial_function.pyi +219 -120
  102. modal/proxy.py +15 -12
  103. modal/proxy.pyi +3 -8
  104. modal/queue.py +96 -72
  105. modal/queue.pyi +210 -135
  106. modal/requirements/2024.04.txt +2 -1
  107. modal/requirements/2024.10.txt +16 -0
  108. modal/requirements/README.md +21 -0
  109. modal/requirements/base-images.json +22 -0
  110. modal/retries.py +45 -4
  111. modal/runner.py +325 -203
  112. modal/runner.pyi +124 -110
  113. modal/running_app.py +27 -4
  114. modal/sandbox.py +509 -231
  115. modal/sandbox.pyi +396 -169
  116. modal/schedule.py +2 -2
  117. modal/scheduler_placement.py +20 -3
  118. modal/secret.py +41 -25
  119. modal/secret.pyi +62 -42
  120. modal/serving.py +39 -49
  121. modal/serving.pyi +37 -43
  122. modal/stream_type.py +15 -0
  123. modal/token_flow.py +5 -3
  124. modal/token_flow.pyi +37 -32
  125. modal/volume.py +123 -137
  126. modal/volume.pyi +228 -221
  127. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/METADATA +5 -5
  128. modal-0.72.11.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  130. modal_docs/gen_reference_docs.py +3 -1
  131. modal_docs/mdmd/mdmd.py +0 -1
  132. modal_docs/mdmd/signatures.py +1 -2
  133. modal_global_objects/images/base_images.py +28 -0
  134. modal_global_objects/mounts/python_standalone.py +2 -2
  135. modal_proto/__init__.py +1 -1
  136. modal_proto/api.proto +1231 -531
  137. modal_proto/api_grpc.py +750 -430
  138. modal_proto/api_pb2.py +2102 -1176
  139. modal_proto/api_pb2.pyi +8859 -0
  140. modal_proto/api_pb2_grpc.py +1329 -675
  141. modal_proto/api_pb2_grpc.pyi +1416 -0
  142. modal_proto/modal_api_grpc.py +149 -0
  143. modal_proto/modal_options_grpc.py +3 -0
  144. modal_proto/options_pb2.pyi +20 -0
  145. modal_proto/options_pb2_grpc.pyi +7 -0
  146. modal_proto/py.typed +0 -0
  147. modal_version/__init__.py +1 -1
  148. modal_version/_version_generated.py +2 -2
  149. modal/_asgi.py +0 -370
  150. modal/_container_exec.py +0 -128
  151. modal/_container_io_manager.py +0 -646
  152. modal/_container_io_manager.pyi +0 -412
  153. modal/_sandbox_shell.py +0 -49
  154. modal/app_utils.py +0 -20
  155. modal/app_utils.pyi +0 -17
  156. modal/execution_context.pyi +0 -37
  157. modal/shared_volume.py +0 -23
  158. modal/shared_volume.pyi +0 -24
  159. modal-0.62.115.dist-info/RECORD +0 -207
  160. modal_global_objects/images/conda.py +0 -15
  161. modal_global_objects/images/debian_slim.py +0 -15
  162. modal_global_objects/images/micromamba.py +0 -15
  163. test/__init__.py +0 -1
  164. test/aio_test.py +0 -12
  165. test/async_utils_test.py +0 -279
  166. test/blob_test.py +0 -67
  167. test/cli_imports_test.py +0 -149
  168. test/cli_test.py +0 -674
  169. test/client_test.py +0 -203
  170. test/cloud_bucket_mount_test.py +0 -22
  171. test/cls_test.py +0 -636
  172. test/config_test.py +0 -149
  173. test/conftest.py +0 -1485
  174. test/container_app_test.py +0 -50
  175. test/container_test.py +0 -1405
  176. test/cpu_test.py +0 -23
  177. test/decorator_test.py +0 -85
  178. test/deprecation_test.py +0 -34
  179. test/dict_test.py +0 -51
  180. test/e2e_test.py +0 -68
  181. test/error_test.py +0 -7
  182. test/function_serialization_test.py +0 -32
  183. test/function_test.py +0 -791
  184. test/function_utils_test.py +0 -101
  185. test/gpu_test.py +0 -159
  186. test/grpc_utils_test.py +0 -82
  187. test/helpers.py +0 -47
  188. test/image_test.py +0 -814
  189. test/live_reload_test.py +0 -80
  190. test/lookup_test.py +0 -70
  191. test/mdmd_test.py +0 -329
  192. test/mount_test.py +0 -162
  193. test/mounted_files_test.py +0 -327
  194. test/network_file_system_test.py +0 -188
  195. test/notebook_test.py +0 -66
  196. test/object_test.py +0 -41
  197. test/package_utils_test.py +0 -25
  198. test/queue_test.py +0 -115
  199. test/resolver_test.py +0 -59
  200. test/retries_test.py +0 -67
  201. test/runner_test.py +0 -85
  202. test/sandbox_test.py +0 -191
  203. test/schedule_test.py +0 -15
  204. test/scheduler_placement_test.py +0 -57
  205. test/secret_test.py +0 -89
  206. test/serialization_test.py +0 -50
  207. test/stub_composition_test.py +0 -10
  208. test/stub_test.py +0 -361
  209. test/test_asgi_wrapper.py +0 -234
  210. test/token_flow_test.py +0 -18
  211. test/traceback_test.py +0 -135
  212. test/tunnel_test.py +0 -29
  213. test/utils_test.py +0 -88
  214. test/version_test.py +0 -14
  215. test/volume_test.py +0 -397
  216. test/watcher_test.py +0 -58
  217. test/webhook_test.py +0 -145
  218. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/token_flow.pyi CHANGED
@@ -5,46 +5,51 @@ import typing
5
5
  import typing_extensions
6
6
 
7
7
  class _TokenFlow:
8
- def __init__(self, client: modal.client._Client):
9
- ...
10
-
11
- def start(self, utm_source: typing.Union[str, None] = None, next_url: typing.Union[str, None] = None) -> typing.AsyncContextManager[typing.Tuple[str, str, str]]:
12
- ...
13
-
14
- async def finish(self, timeout: float = 40.0, grpc_extra_timeout: float = 5.0) -> typing.Union[modal_proto.api_pb2.TokenFlowWaitResponse, None]:
15
- ...
16
-
8
+ def __init__(self, client: modal.client._Client): ...
9
+ def start(
10
+ self, utm_source: typing.Optional[str] = None, next_url: typing.Optional[str] = None
11
+ ) -> typing.AsyncContextManager[tuple[str, str, str]]: ...
12
+ async def finish(
13
+ self, timeout: float = 40.0, grpc_extra_timeout: float = 5.0
14
+ ) -> typing.Optional[modal_proto.api_pb2.TokenFlowWaitResponse]: ...
17
15
 
18
16
  class TokenFlow:
19
- def __init__(self, client: modal.client.Client):
20
- ...
17
+ def __init__(self, client: modal.client.Client): ...
21
18
 
22
19
  class __start_spec(typing_extensions.Protocol):
23
- def __call__(self, utm_source: typing.Union[str, None] = None, next_url: typing.Union[str, None] = None) -> synchronicity.combined_types.AsyncAndBlockingContextManager[typing.Tuple[str, str, str]]:
24
- ...
25
-
26
- def aio(self, utm_source: typing.Union[str, None] = None, next_url: typing.Union[str, None] = None) -> typing.AsyncContextManager[typing.Tuple[str, str, str]]:
27
- ...
20
+ def __call__(
21
+ self, utm_source: typing.Optional[str] = None, next_url: typing.Optional[str] = None
22
+ ) -> synchronicity.combined_types.AsyncAndBlockingContextManager[tuple[str, str, str]]: ...
23
+ def aio(
24
+ self, utm_source: typing.Optional[str] = None, next_url: typing.Optional[str] = None
25
+ ) -> typing.AsyncContextManager[tuple[str, str, str]]: ...
28
26
 
29
27
  start: __start_spec
30
28
 
31
29
  class __finish_spec(typing_extensions.Protocol):
32
- def __call__(self, timeout: float = 40.0, grpc_extra_timeout: float = 5.0) -> typing.Union[modal_proto.api_pb2.TokenFlowWaitResponse, None]:
33
- ...
34
-
35
- async def aio(self, *args, **kwargs) -> typing.Union[modal_proto.api_pb2.TokenFlowWaitResponse, None]:
36
- ...
30
+ def __call__(
31
+ self, timeout: float = 40.0, grpc_extra_timeout: float = 5.0
32
+ ) -> typing.Optional[modal_proto.api_pb2.TokenFlowWaitResponse]: ...
33
+ async def aio(
34
+ self, timeout: float = 40.0, grpc_extra_timeout: float = 5.0
35
+ ) -> typing.Optional[modal_proto.api_pb2.TokenFlowWaitResponse]: ...
37
36
 
38
37
  finish: __finish_spec
39
38
 
40
-
41
- async def _new_token(*, profile: typing.Union[str, None] = None, activate: bool = True, verify: bool = True, source: typing.Union[str, None] = None, next_url: typing.Union[str, None] = None):
42
- ...
43
-
44
-
45
- async def _set_token(token_id: str, token_secret: str, *, profile: typing.Union[str, None] = None, activate: bool = True, verify: bool = True):
46
- ...
47
-
48
-
49
- def _open_url(url: str) -> bool:
50
- ...
39
+ async def _new_token(
40
+ *,
41
+ profile: typing.Optional[str] = None,
42
+ activate: bool = True,
43
+ verify: bool = True,
44
+ source: typing.Optional[str] = None,
45
+ next_url: typing.Optional[str] = None,
46
+ ): ...
47
+ async def _set_token(
48
+ token_id: str,
49
+ token_secret: str,
50
+ *,
51
+ profile: typing.Optional[str] = None,
52
+ activate: bool = True,
53
+ verify: bool = True,
54
+ ): ...
55
+ def _open_url(url: str) -> bool: ...
modal/volume.py CHANGED
@@ -2,35 +2,33 @@
2
2
  import asyncio
3
3
  import concurrent.futures
4
4
  import enum
5
+ import functools
5
6
  import os
6
7
  import platform
7
8
  import re
8
9
  import time
10
+ import typing
11
+ from collections.abc import AsyncGenerator, AsyncIterator, Generator, Sequence
9
12
  from dataclasses import dataclass
10
13
  from pathlib import Path, PurePosixPath
11
14
  from typing import (
12
15
  IO,
13
- AsyncGenerator,
14
- AsyncIterator,
16
+ Any,
15
17
  BinaryIO,
16
18
  Callable,
17
- Generator,
18
- List,
19
19
  Optional,
20
- Sequence,
21
- Type,
22
20
  Union,
23
21
  )
24
22
 
25
- import aiostream
26
23
  from grpclib import GRPCError, Status
27
24
  from synchronicity.async_wrap import asynccontextmanager
28
25
 
29
- from modal.exception import InvalidError, VolumeUploadTimeoutError, deprecation_warning
26
+ import modal_proto.api_pb2
27
+ from modal.exception import VolumeUploadTimeoutError
30
28
  from modal_proto import api_pb2
31
29
 
32
30
  from ._resolver import Resolver
33
- from ._utils.async_utils import TaskContext, asyncnullcontext, synchronize_api
31
+ from ._utils.async_utils import TaskContext, aclosing, async_map, asyncnullcontext, synchronize_api
34
32
  from ._utils.blob_utils import (
35
33
  FileUploadSpec,
36
34
  blob_iter,
@@ -38,15 +36,16 @@ from ._utils.blob_utils import (
38
36
  get_file_upload_spec_from_fileobj,
39
37
  get_file_upload_spec_from_path,
40
38
  )
41
- from ._utils.grpc_utils import retry_transient_errors, unary_stream
39
+ from ._utils.deprecation import deprecation_error, deprecation_warning, renamed_parameter
40
+ from ._utils.grpc_utils import retry_transient_errors
41
+ from ._utils.name_utils import check_object_name
42
42
  from .client import _Client
43
43
  from .config import logger
44
- from .exception import deprecation_error
45
44
  from .object import EPHEMERAL_OBJECT_HEARTBEAT_SLEEP, _get_environment_name, _Object, live_method, live_method_gen
46
45
 
47
46
  # Max duration for uploading to volumes files
48
47
  # As a guide, files >40GiB will take >10 minutes to upload.
49
- VOLUME_PUT_FILE_CLIENT_TIMEOUT = 30 * 60
48
+ VOLUME_PUT_FILE_CLIENT_TIMEOUT = 60 * 60
50
49
 
51
50
 
52
51
  class FileEntryType(enum.IntEnum):
@@ -76,12 +75,6 @@ class FileEntry:
76
75
  size=proto.size,
77
76
  )
78
77
 
79
- def __getattr__(self, name: str):
80
- deprecation_error(
81
- (2024, 4, 15),
82
- f"The FileEntry dataclass was introduced to replace a private Protobuf message. This dataclass does not have the {name} attribute.",
83
- )
84
-
85
78
 
86
79
  class _Volume(_Object, type_prefix="vo"):
87
80
  """A writeable volume that can be used to share files between one or more Modal functions.
@@ -108,7 +101,7 @@ class _Volume(_Object, type_prefix="vo"):
108
101
  ```python
109
102
  import modal
110
103
 
111
- app = modal.App() # Note: "app" was called "stub" up until April 2024
104
+ app = modal.App()
112
105
  volume = modal.Volume.from_name("my-persisted-volume", create_if_missing=True)
113
106
 
114
107
  @app.function(volumes={"/root/foo": volume})
@@ -125,69 +118,57 @@ class _Volume(_Object, type_prefix="vo"):
125
118
  ```
126
119
  """
127
120
 
128
- _lock: asyncio.Lock
121
+ _lock: Optional[asyncio.Lock] = None
129
122
 
130
- def _initialize_from_empty(self):
123
+ async def _get_lock(self):
131
124
  # To (mostly*) prevent multiple concurrent operations on the same volume, which can cause problems under
132
125
  # some unlikely circumstances.
133
126
  # *: You can bypass this by creating multiple handles to the same volume, e.g. via lookup. But this
134
127
  # covers the typical case = good enough.
135
- self._lock = asyncio.Lock()
136
-
137
- @staticmethod
138
- def new() -> "_Volume":
139
- """`Volume.new` is deprecated.
140
128
 
141
- Please use `Volume.from_name` (for persisted) or `Volume.ephemeral` (for ephemeral) volumes.
142
- """
143
- deprecation_warning((2024, 3, 20), Volume.new.__doc__)
144
-
145
- async def _load(self: _Volume, resolver: Resolver, existing_object_id: Optional[str]):
146
- status_row = resolver.add_status_row()
147
- if existing_object_id:
148
- # Volume already exists; do nothing.
149
- self._hydrate(existing_object_id, resolver.client, None)
150
- return
151
-
152
- status_row.message("Creating volume...")
153
- req = api_pb2.VolumeCreateRequest(app_id=resolver.app_id)
154
- resp = await retry_transient_errors(resolver.client.stub.VolumeCreate, req)
155
- status_row.finish("Created volume.")
156
- self._hydrate(resp.volume_id, resolver.client, None)
157
-
158
- return _Volume._from_loader(_load, "Volume()")
129
+ # Note: this function runs no async code but is marked as async to ensure it's
130
+ # being run inside the synchronicity event loop and binds the lock to the
131
+ # correct event loop on Python 3.9 which eagerly assigns event loops on
132
+ # constructions of locks
133
+ if self._lock is None:
134
+ self._lock = asyncio.Lock()
135
+ return self._lock
159
136
 
160
137
  @staticmethod
138
+ @renamed_parameter((2024, 12, 18), "label", "name")
161
139
  def from_name(
162
- label: str,
140
+ name: str,
163
141
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
164
142
  environment_name: Optional[str] = None,
165
143
  create_if_missing: bool = False,
144
+ version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
166
145
  ) -> "_Volume":
167
- """Create a reference to a persisted volume. Optionally create it lazily.
146
+ """Reference a Volume by name, creating if necessary.
168
147
 
169
- **Example Usage**
148
+ In contrast to `modal.Volume.lookup`, this is a lazy method
149
+ that defers hydrating the local object with metadata from
150
+ Modal servers until the first time is is actually used.
170
151
 
171
152
  ```python
172
- import modal
173
-
174
- volume = modal.Volume.from_name("my-volume", create_if_missing=True)
153
+ vol = modal.Volume.from_name("my-volume", create_if_missing=True)
175
154
 
176
- app = modal.App() # Note: "app" was called "stub" up until April 2024
155
+ app = modal.App()
177
156
 
178
157
  # Volume refers to the same object, even across instances of `app`.
179
- @app.function(volumes={"/vol": volume})
158
+ @app.function(volumes={"/data": vol})
180
159
  def f():
181
160
  pass
182
161
  ```
183
162
  """
163
+ check_object_name(name, "Volume")
184
164
 
185
165
  async def _load(self: _Volume, resolver: Resolver, existing_object_id: Optional[str]):
186
166
  req = api_pb2.VolumeGetOrCreateRequest(
187
- deployment_name=label,
167
+ deployment_name=name,
188
168
  namespace=namespace,
189
169
  environment_name=_get_environment_name(environment_name, resolver),
190
170
  object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
171
+ version=version,
191
172
  )
192
173
  response = await resolver.client.stub.VolumeGetOrCreate(req)
193
174
  self._hydrate(response.volume_id, resolver.client, None)
@@ -197,20 +178,24 @@ class _Volume(_Object, type_prefix="vo"):
197
178
  @classmethod
198
179
  @asynccontextmanager
199
180
  async def ephemeral(
200
- cls: Type["_Volume"],
181
+ cls: type["_Volume"],
201
182
  client: Optional[_Client] = None,
202
183
  environment_name: Optional[str] = None,
184
+ version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
203
185
  _heartbeat_sleep: float = EPHEMERAL_OBJECT_HEARTBEAT_SLEEP,
204
- ) -> AsyncIterator["_Volume"]:
186
+ ) -> AsyncGenerator["_Volume", None]:
205
187
  """Creates a new ephemeral volume within a context manager:
206
188
 
207
189
  Usage:
208
190
  ```python
209
- with Volume.ephemeral() as vol:
210
- assert vol.listdir() == []
191
+ import modal
192
+ with modal.Volume.ephemeral() as vol:
193
+ assert vol.listdir("/") == []
194
+ ```
211
195
 
212
- async with Volume.ephemeral() as vol:
213
- assert await vol.listdir() == []
196
+ ```python notest
197
+ async with modal.Volume.ephemeral() as vol:
198
+ assert await vol.listdir("/") == []
214
199
  ```
215
200
  """
216
201
  if client is None:
@@ -218,6 +203,7 @@ class _Volume(_Object, type_prefix="vo"):
218
203
  request = api_pb2.VolumeGetOrCreateRequest(
219
204
  object_creation_type=api_pb2.OBJECT_CREATION_TYPE_EPHEMERAL,
220
205
  environment_name=_get_environment_name(environment_name),
206
+ version=version,
221
207
  )
222
208
  response = await client.stub.VolumeGetOrCreate(request)
223
209
  async with TaskContext() as tc:
@@ -226,33 +212,31 @@ class _Volume(_Object, type_prefix="vo"):
226
212
  yield cls._new_hydrated(response.volume_id, client, None, is_another_app=True)
227
213
 
228
214
  @staticmethod
229
- def persisted(
230
- label: str,
231
- namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
232
- environment_name: Optional[str] = None,
233
- cloud: Optional[str] = None,
234
- ) -> "_Volume":
235
- """Deprecated! Use `Volume.from_name(name, create_if_missing=True)`."""
236
- deprecation_warning((2024, 3, 1), _Volume.persisted.__doc__)
237
- return _Volume.from_name(label, namespace, environment_name, create_if_missing=True)
238
-
239
- @staticmethod
215
+ @renamed_parameter((2024, 12, 18), "label", "name")
240
216
  async def lookup(
241
- label: str,
217
+ name: str,
242
218
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
243
219
  client: Optional[_Client] = None,
244
220
  environment_name: Optional[str] = None,
245
221
  create_if_missing: bool = False,
222
+ version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
246
223
  ) -> "_Volume":
247
- """Lookup a volume with a given name
224
+ """Lookup a named Volume.
248
225
 
249
- ```python
250
- n = modal.Volume.lookup("my-volume")
251
- print(n.listdir("/"))
226
+ In contrast to `modal.Volume.from_name`, this is an eager method
227
+ that will hydrate the local object with metadata from Modal servers.
228
+
229
+ ```python notest
230
+ vol = modal.Volume.lookup("my-volume")
231
+ print(vol.listdir("/"))
252
232
  ```
253
233
  """
254
234
  obj = _Volume.from_name(
255
- label, namespace=namespace, environment_name=environment_name, create_if_missing=create_if_missing
235
+ name,
236
+ namespace=namespace,
237
+ environment_name=environment_name,
238
+ create_if_missing=create_if_missing,
239
+ version=version,
256
240
  )
257
241
  if client is None:
258
242
  client = await _Client.from_env()
@@ -266,8 +250,10 @@ class _Volume(_Object, type_prefix="vo"):
266
250
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
267
251
  client: Optional[_Client] = None,
268
252
  environment_name: Optional[str] = None,
253
+ version: "typing.Optional[modal_proto.api_pb2.VolumeFsVersion.ValueType]" = None,
269
254
  ) -> str:
270
255
  """mdmd:hidden"""
256
+ check_object_name(deployment_name, "Volume")
271
257
  if client is None:
272
258
  client = await _Client.from_env()
273
259
  request = api_pb2.VolumeGetOrCreateRequest(
@@ -275,13 +261,14 @@ class _Volume(_Object, type_prefix="vo"):
275
261
  namespace=namespace,
276
262
  environment_name=_get_environment_name(environment_name),
277
263
  object_creation_type=api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS,
264
+ version=version,
278
265
  )
279
266
  resp = await retry_transient_errors(client.stub.VolumeGetOrCreate, request)
280
267
  return resp.volume_id
281
268
 
282
269
  @live_method
283
270
  async def _do_reload(self, lock=True):
284
- async with self._lock if lock else asyncnullcontext():
271
+ async with (await self._get_lock()) if lock else asyncnullcontext():
285
272
  req = api_pb2.VolumeReloadRequest(volume_id=self.object_id)
286
273
  _ = await retry_transient_errors(self._client.stub.VolumeReload, req)
287
274
 
@@ -292,7 +279,7 @@ class _Volume(_Object, type_prefix="vo"):
292
279
  If successful, the changes made are now persisted in durable storage and available to other containers accessing
293
280
  the volume.
294
281
  """
295
- async with self._lock:
282
+ async with await self._get_lock():
296
283
  req = api_pb2.VolumeCommitRequest(volume_id=self.object_id)
297
284
  try:
298
285
  # TODO(gongy): only apply indefinite retries on 504 status.
@@ -343,23 +330,30 @@ class _Volume(_Object, type_prefix="vo"):
343
330
  # This allows us to remove the server shim after 0.62 is no longer supported.
344
331
  deprecation = deprecation_warning if (major_number, minor_number) <= (0, 62) else deprecation_error
345
332
  if path.endswith("**"):
333
+ msg = (
334
+ "Glob patterns in `volume get` and `Volume.listdir()` are deprecated. "
335
+ "Please pass recursive=True instead. For the CLI, just remove the glob suffix."
336
+ )
346
337
  deprecation(
347
338
  (2024, 4, 23),
348
- "Glob patterns in `volume get` and `Volume.listdir()` are deprecated. Please pass recursive=True instead. For the CLI, just remove the glob suffix.",
339
+ msg,
349
340
  )
350
341
  elif path.endswith("*"):
351
342
  deprecation(
352
343
  (2024, 4, 23),
353
- "Glob patterns in `volume get` and `Volume.listdir()` are deprecated. Please remove the glob `*` suffix.",
344
+ (
345
+ "Glob patterns in `volume get` and `Volume.listdir()` are deprecated. "
346
+ "Please remove the glob `*` suffix."
347
+ ),
354
348
  )
355
349
 
356
350
  req = api_pb2.VolumeListFilesRequest(volume_id=self.object_id, path=path, recursive=recursive)
357
- async for batch in unary_stream(self._client.stub.VolumeListFiles, req):
351
+ async for batch in self._client.stub.VolumeListFiles.unary_stream(req):
358
352
  for entry in batch.entries:
359
353
  yield FileEntry._from_proto(entry)
360
354
 
361
355
  @live_method
362
- async def listdir(self, path: str, *, recursive: bool = False) -> List[FileEntry]:
356
+ async def listdir(self, path: str, *, recursive: bool = False) -> list[FileEntry]:
363
357
  """List all files under a path prefix in the modal.Volume.
364
358
 
365
359
  Passing a directory path lists all files in the directory. For a file path, return only that
@@ -369,7 +363,7 @@ class _Volume(_Object, type_prefix="vo"):
369
363
  return [entry async for entry in self.iterdir(path, recursive=recursive)]
370
364
 
371
365
  @live_method_gen
372
- async def read_file(self, path: Union[str, bytes]) -> AsyncIterator[bytes]:
366
+ async def read_file(self, path: str) -> AsyncIterator[bytes]:
373
367
  """
374
368
  Read a file from the modal.Volume.
375
369
 
@@ -383,8 +377,6 @@ class _Volume(_Object, type_prefix="vo"):
383
377
  print(len(data)) # == 1024 * 1024
384
378
  ```
385
379
  """
386
- if isinstance(path, str):
387
- path = path.encode("utf-8")
388
380
  req = api_pb2.VolumeGetFileRequest(volume_id=self.object_id, path=path)
389
381
  try:
390
382
  response = await retry_transient_errors(self._client.stub.VolumeGetFile, req)
@@ -399,14 +391,12 @@ class _Volume(_Object, type_prefix="vo"):
399
391
  yield data
400
392
 
401
393
  @live_method
402
- async def read_file_into_fileobj(self, path: Union[str, bytes], fileobj: IO[bytes]) -> int:
394
+ async def read_file_into_fileobj(self, path: str, fileobj: IO[bytes]) -> int:
403
395
  """mdmd:hidden
404
396
 
405
397
  Read volume file into file-like IO object.
406
398
  In the future, this will replace the current generator implementation of the `read_file` method.
407
399
  """
408
- if isinstance(path, str):
409
- path = path.encode("utf-8")
410
400
 
411
401
  chunk_size_bytes = 8 * 1024 * 1024
412
402
  start = 0
@@ -420,7 +410,7 @@ class _Volume(_Object, type_prefix="vo"):
420
410
 
421
411
  n = fileobj.write(response.data)
422
412
  if n != len(response.data):
423
- raise IOError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
413
+ raise OSError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
424
414
  elif n == response.size:
425
415
  return response.size
426
416
  elif n > response.size:
@@ -441,7 +431,7 @@ class _Volume(_Object, type_prefix="vo"):
441
431
 
442
432
  n = fileobj.write(response.data)
443
433
  if n != len(response.data):
444
- raise IOError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
434
+ raise OSError(f"failed to write {len(response.data)} bytes to output. Wrote {n}.")
445
435
  written += n
446
436
  if written == file_size:
447
437
  break
@@ -449,15 +439,13 @@ class _Volume(_Object, type_prefix="vo"):
449
439
  return written
450
440
 
451
441
  @live_method
452
- async def remove_file(self, path: Union[str, bytes], recursive: bool = False) -> None:
442
+ async def remove_file(self, path: str, recursive: bool = False) -> None:
453
443
  """Remove a file or directory from a volume."""
454
- if isinstance(path, str):
455
- path = path.encode("utf-8")
456
444
  req = api_pb2.VolumeRemoveFileRequest(volume_id=self.object_id, path=path, recursive=recursive)
457
445
  await retry_transient_errors(self._client.stub.VolumeRemoveFile, req)
458
446
 
459
447
  @live_method
460
- async def copy_files(self, src_paths: Sequence[Union[str, bytes]], dst_path: Union[str, bytes]) -> None:
448
+ async def copy_files(self, src_paths: Sequence[str], dst_path: str) -> None:
461
449
  """
462
450
  Copy files within the volume from src_paths to dst_path.
463
451
  The semantics of the copy operation follow those of the UNIX cp command.
@@ -481,10 +469,6 @@ class _Volume(_Object, type_prefix="vo"):
481
469
  like `os.rename()` and then `commit()` the volume. The `copy_files()` method is useful when you don't have
482
470
  the volume mounted as a filesystem, e.g. when running a script on your local computer.
483
471
  """
484
- src_paths = [path.encode("utf-8") for path in src_paths if isinstance(path, str)]
485
- if isinstance(dst_path, str):
486
- dst_path = dst_path.encode("utf-8")
487
-
488
472
  request = api_pb2.VolumeCopyFilesRequest(volume_id=self.object_id, src_paths=src_paths, dst_path=dst_path)
489
473
  await retry_transient_errors(self._client.stub.VolumeCopyFiles, request, base_delay=1)
490
474
 
@@ -515,32 +499,25 @@ class _Volume(_Object, type_prefix="vo"):
515
499
  self._client.stub.VolumeDelete, api_pb2.VolumeDeleteRequest(volume_id=self.object_id)
516
500
  )
517
501
 
518
- # @staticmethod # TODO uncomment when enforcing deprecation of instance method invocation
519
- async def delete(*args, label: str = "", client: Optional[_Client] = None, environment_name: Optional[str] = None):
520
- # -- Backwards-compatibility section
521
- # TODO(michael) Upon enforcement of this deprecation, remove *args and the default argument for label=.
522
- if isinstance(self := args[0], _Volume):
523
- msg = (
524
- "Calling Volume.delete as an instance method is deprecated."
525
- " Please update your code to call it as a static method, passing"
526
- " the name of the volume to delete, e.g. `modal.Volume.delete('my-volume')`."
527
- )
528
- deprecation_warning((2024, 4, 23), msg)
529
- await self._instance_delete()
530
- return
531
- elif isinstance(args[0], type):
532
- args = args[1:]
533
-
534
- if args and isinstance(args[0], str):
535
- if label:
536
- raise InvalidError("`label` specified as both positional and keyword argument")
537
- label = args[0]
538
- # -- Backwards-compatibility code ends here
539
-
540
- obj = await _Volume.lookup(label, client=client, environment_name=environment_name)
502
+ @staticmethod
503
+ @renamed_parameter((2024, 12, 18), "label", "name")
504
+ async def delete(name: str, client: Optional[_Client] = None, environment_name: Optional[str] = None):
505
+ obj = await _Volume.lookup(name, client=client, environment_name=environment_name)
541
506
  req = api_pb2.VolumeDeleteRequest(volume_id=obj.object_id)
542
507
  await retry_transient_errors(obj._client.stub.VolumeDelete, req)
543
508
 
509
+ @staticmethod
510
+ async def rename(
511
+ old_name: str,
512
+ new_name: str,
513
+ *,
514
+ client: Optional[_Client] = None,
515
+ environment_name: Optional[str] = None,
516
+ ):
517
+ obj = await _Volume.lookup(old_name, client=client, environment_name=environment_name)
518
+ req = api_pb2.VolumeRenameRequest(volume_id=obj.object_id, name=new_name)
519
+ await retry_transient_errors(obj._client.stub.VolumeRename, req)
520
+
544
521
 
545
522
  class _VolumeUploadContextManager:
546
523
  """Context manager for batch-uploading files to a Volume."""
@@ -548,13 +525,17 @@ class _VolumeUploadContextManager:
548
525
  _volume_id: str
549
526
  _client: _Client
550
527
  _force: bool
551
- _upload_generators: List[Generator[Callable[[], FileUploadSpec], None, None]]
528
+ progress_cb: Callable[..., Any]
529
+ _upload_generators: list[Generator[Callable[[], FileUploadSpec], None, None]]
552
530
 
553
- def __init__(self, volume_id: str, client: _Client, force: bool = False):
531
+ def __init__(
532
+ self, volume_id: str, client: _Client, progress_cb: Optional[Callable[..., Any]] = None, force: bool = False
533
+ ):
554
534
  """mdmd:hidden"""
555
535
  self._volume_id = volume_id
556
536
  self._client = client
557
537
  self._upload_generators = []
538
+ self._progress_cb = progress_cb or (lambda *_, **__: None)
558
539
  self._force = force
559
540
 
560
541
  async def __aenter__(self):
@@ -576,11 +557,13 @@ class _VolumeUploadContextManager:
576
557
  for fut in asyncio.as_completed(futs):
577
558
  yield await fut
578
559
 
579
- # Compute checksums
580
- files_stream = aiostream.stream.iterate(gen_file_upload_specs())
581
- # Upload files
582
- uploads_stream = aiostream.stream.map(files_stream, self._upload_file, task_limit=20)
583
- files: List[api_pb2.MountFile] = await aiostream.stream.list(uploads_stream)
560
+ # Compute checksums & Upload files
561
+ files: list[api_pb2.MountFile] = []
562
+ async with aclosing(async_map(gen_file_upload_specs(), self._upload_file, concurrency=20)) as stream:
563
+ async for item in stream:
564
+ files.append(item)
565
+
566
+ self._progress_cb(complete=True)
584
567
 
585
568
  request = api_pb2.VolumePutFilesRequest(
586
569
  volume_id=self._volume_id,
@@ -646,7 +629,7 @@ class _VolumeUploadContextManager:
646
629
 
647
630
  async def _upload_file(self, file_spec: FileUploadSpec) -> api_pb2.MountFile:
648
631
  remote_filename = file_spec.mount_filename
649
-
632
+ progress_task_id = self._progress_cb(name=remote_filename, size=file_spec.size)
650
633
  request = api_pb2.MountPutFileRequest(sha256_hex=file_spec.sha256_hex)
651
634
  response = await retry_transient_errors(self._client.stub.MountPutFile, request, base_delay=1)
652
635
 
@@ -655,7 +638,13 @@ class _VolumeUploadContextManager:
655
638
  if file_spec.use_blob:
656
639
  logger.debug(f"Creating blob file for {file_spec.source_description} ({file_spec.size} bytes)")
657
640
  with file_spec.source() as fp:
658
- blob_id = await blob_upload_file(fp, self._client.stub)
641
+ blob_id = await blob_upload_file(
642
+ fp,
643
+ self._client.stub,
644
+ functools.partial(self._progress_cb, progress_task_id),
645
+ sha256_hex=file_spec.sha256_hex,
646
+ md5_hex=file_spec.md5_hex,
647
+ )
659
648
  logger.debug(f"Uploading blob file {file_spec.source_description} as {remote_filename}")
660
649
  request2 = api_pb2.MountPutFileRequest(data_blob_id=blob_id, sha256_hex=file_spec.sha256_hex)
661
650
  else:
@@ -663,6 +652,7 @@ class _VolumeUploadContextManager:
663
652
  f"Uploading file {file_spec.source_description} to {remote_filename} ({file_spec.size} bytes)"
664
653
  )
665
654
  request2 = api_pb2.MountPutFileRequest(data=file_spec.content, sha256_hex=file_spec.sha256_hex)
655
+ self._progress_cb(task_id=progress_task_id, complete=True)
666
656
 
667
657
  while (time.monotonic() - start_time) < VOLUME_PUT_FILE_CLIENT_TIMEOUT:
668
658
  response = await retry_transient_errors(self._client.stub.MountPutFile, request2, base_delay=1)
@@ -671,7 +661,8 @@ class _VolumeUploadContextManager:
671
661
 
672
662
  if not response.exists:
673
663
  raise VolumeUploadTimeoutError(f"Uploading of {file_spec.source_description} timed out")
674
-
664
+ else:
665
+ self._progress_cb(task_id=progress_task_id, complete=True)
675
666
  return api_pb2.MountFile(
676
667
  filename=remote_filename,
677
668
  sha256_hex=file_spec.sha256_hex,
@@ -697,16 +688,11 @@ def _open_files_error_annotation(mount_path: str) -> Optional[str]:
697
688
  cmdline = " ".join([part.decode() for part in parts]).rstrip(" ")
698
689
 
699
690
  cwd = PurePosixPath(os.readlink(f"/proc/{pid}/cwd"))
700
- # NOTE(staffan): Python 3.8 doesn't have is_relative_to(), so we're stuck with catching ValueError until
701
- # we drop Python 3.8 support.
702
- try:
703
- _rel_cwd = cwd.relative_to(mount_path)
691
+ if cwd.is_relative_to(mount_path):
704
692
  if pid == self_pid:
705
693
  return "cwd is inside volume"
706
694
  else:
707
695
  return f"cwd of '{cmdline}' is inside volume"
708
- except ValueError:
709
- pass
710
696
 
711
697
  for fd in os.listdir(f"/proc/{pid}/fd"):
712
698
  try: