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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. modal/__init__.py +17 -13
  2. modal/__main__.py +41 -3
  3. modal/_clustered_functions.py +80 -0
  4. modal/_clustered_functions.pyi +22 -0
  5. modal/_container_entrypoint.py +420 -937
  6. modal/_ipython.py +3 -13
  7. modal/_location.py +17 -10
  8. modal/_output.py +243 -99
  9. modal/_pty.py +2 -2
  10. modal/_resolver.py +55 -59
  11. modal/_resources.py +51 -0
  12. modal/_runtime/__init__.py +1 -0
  13. modal/_runtime/asgi.py +519 -0
  14. modal/_runtime/container_io_manager.py +1036 -0
  15. modal/_runtime/execution_context.py +89 -0
  16. modal/_runtime/telemetry.py +169 -0
  17. modal/_runtime/user_code_imports.py +356 -0
  18. modal/_serialization.py +134 -9
  19. modal/_traceback.py +47 -187
  20. modal/_tunnel.py +52 -16
  21. modal/_tunnel.pyi +19 -36
  22. modal/_utils/app_utils.py +3 -17
  23. modal/_utils/async_utils.py +479 -100
  24. modal/_utils/blob_utils.py +157 -186
  25. modal/_utils/bytes_io_segment_payload.py +97 -0
  26. modal/_utils/deprecation.py +89 -0
  27. modal/_utils/docker_utils.py +98 -0
  28. modal/_utils/function_utils.py +460 -171
  29. modal/_utils/grpc_testing.py +47 -31
  30. modal/_utils/grpc_utils.py +62 -109
  31. modal/_utils/hash_utils.py +61 -19
  32. modal/_utils/http_utils.py +39 -9
  33. modal/_utils/logger.py +2 -1
  34. modal/_utils/mount_utils.py +34 -16
  35. modal/_utils/name_utils.py +58 -0
  36. modal/_utils/package_utils.py +14 -1
  37. modal/_utils/pattern_utils.py +205 -0
  38. modal/_utils/rand_pb_testing.py +5 -7
  39. modal/_utils/shell_utils.py +15 -49
  40. modal/_vendor/a2wsgi_wsgi.py +62 -72
  41. modal/_vendor/cloudpickle.py +1 -1
  42. modal/_watcher.py +14 -12
  43. modal/app.py +1003 -314
  44. modal/app.pyi +540 -264
  45. modal/call_graph.py +7 -6
  46. modal/cli/_download.py +63 -53
  47. modal/cli/_traceback.py +200 -0
  48. modal/cli/app.py +205 -45
  49. modal/cli/config.py +12 -5
  50. modal/cli/container.py +62 -14
  51. modal/cli/dict.py +128 -0
  52. modal/cli/entry_point.py +26 -13
  53. modal/cli/environment.py +40 -9
  54. modal/cli/import_refs.py +64 -58
  55. modal/cli/launch.py +32 -18
  56. modal/cli/network_file_system.py +64 -83
  57. modal/cli/profile.py +1 -1
  58. modal/cli/programs/run_jupyter.py +35 -10
  59. modal/cli/programs/vscode.py +60 -10
  60. modal/cli/queues.py +131 -0
  61. modal/cli/run.py +234 -131
  62. modal/cli/secret.py +8 -7
  63. modal/cli/token.py +7 -2
  64. modal/cli/utils.py +79 -10
  65. modal/cli/volume.py +110 -109
  66. modal/client.py +250 -144
  67. modal/client.pyi +157 -118
  68. modal/cloud_bucket_mount.py +108 -34
  69. modal/cloud_bucket_mount.pyi +32 -38
  70. modal/cls.py +535 -148
  71. modal/cls.pyi +190 -146
  72. modal/config.py +41 -19
  73. modal/container_process.py +177 -0
  74. modal/container_process.pyi +82 -0
  75. modal/dict.py +111 -65
  76. modal/dict.pyi +136 -131
  77. modal/environments.py +106 -5
  78. modal/environments.pyi +77 -25
  79. modal/exception.py +34 -43
  80. modal/experimental.py +61 -2
  81. modal/extensions/ipython.py +5 -5
  82. modal/file_io.py +537 -0
  83. modal/file_io.pyi +235 -0
  84. modal/file_pattern_matcher.py +197 -0
  85. modal/functions.py +906 -911
  86. modal/functions.pyi +466 -430
  87. modal/gpu.py +57 -44
  88. modal/image.py +1089 -479
  89. modal/image.pyi +584 -228
  90. modal/io_streams.py +434 -0
  91. modal/io_streams.pyi +122 -0
  92. modal/mount.py +314 -101
  93. modal/mount.pyi +241 -235
  94. modal/network_file_system.py +92 -92
  95. modal/network_file_system.pyi +152 -110
  96. modal/object.py +67 -36
  97. modal/object.pyi +166 -143
  98. modal/output.py +63 -0
  99. modal/parallel_map.py +434 -0
  100. modal/parallel_map.pyi +75 -0
  101. modal/partial_function.py +282 -117
  102. modal/partial_function.pyi +222 -129
  103. modal/proxy.py +15 -12
  104. modal/proxy.pyi +3 -8
  105. modal/queue.py +182 -65
  106. modal/queue.pyi +218 -118
  107. modal/requirements/2024.04.txt +29 -0
  108. modal/requirements/2024.10.txt +16 -0
  109. modal/requirements/README.md +21 -0
  110. modal/requirements/base-images.json +22 -0
  111. modal/retries.py +48 -7
  112. modal/runner.py +459 -156
  113. modal/runner.pyi +135 -71
  114. modal/running_app.py +38 -0
  115. modal/sandbox.py +514 -236
  116. modal/sandbox.pyi +397 -169
  117. modal/schedule.py +4 -4
  118. modal/scheduler_placement.py +20 -3
  119. modal/secret.py +56 -31
  120. modal/secret.pyi +62 -42
  121. modal/serving.py +51 -56
  122. modal/serving.pyi +44 -36
  123. modal/stream_type.py +15 -0
  124. modal/token_flow.py +5 -3
  125. modal/token_flow.pyi +37 -32
  126. modal/volume.py +285 -157
  127. modal/volume.pyi +249 -184
  128. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
  129. modal-0.72.11.dist-info/RECORD +174 -0
  130. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/top_level.txt +0 -1
  131. modal_docs/gen_reference_docs.py +3 -1
  132. modal_docs/mdmd/mdmd.py +0 -1
  133. modal_docs/mdmd/signatures.py +5 -2
  134. modal_global_objects/images/base_images.py +28 -0
  135. modal_global_objects/mounts/python_standalone.py +2 -2
  136. modal_proto/__init__.py +1 -1
  137. modal_proto/api.proto +1288 -533
  138. modal_proto/api_grpc.py +856 -456
  139. modal_proto/api_pb2.py +2165 -1157
  140. modal_proto/api_pb2.pyi +8859 -0
  141. modal_proto/api_pb2_grpc.py +1674 -855
  142. modal_proto/api_pb2_grpc.pyi +1416 -0
  143. modal_proto/modal_api_grpc.py +149 -0
  144. modal_proto/modal_options_grpc.py +3 -0
  145. modal_proto/options_pb2.pyi +20 -0
  146. modal_proto/options_pb2_grpc.pyi +7 -0
  147. modal_proto/py.typed +0 -0
  148. modal_version/__init__.py +1 -1
  149. modal_version/_version_generated.py +2 -2
  150. modal/_asgi.py +0 -370
  151. modal/_container_entrypoint.pyi +0 -378
  152. modal/_container_exec.py +0 -128
  153. modal/_sandbox_shell.py +0 -49
  154. modal/shared_volume.py +0 -23
  155. modal/shared_volume.pyi +0 -24
  156. modal/stub.py +0 -783
  157. modal/stub.pyi +0 -332
  158. modal-0.62.16.dist-info/RECORD +0 -198
  159. modal_global_objects/images/conda.py +0 -15
  160. modal_global_objects/images/debian_slim.py +0 -15
  161. modal_global_objects/images/micromamba.py +0 -15
  162. test/__init__.py +0 -1
  163. test/aio_test.py +0 -12
  164. test/async_utils_test.py +0 -262
  165. test/blob_test.py +0 -67
  166. test/cli_imports_test.py +0 -149
  167. test/cli_test.py +0 -659
  168. test/client_test.py +0 -194
  169. test/cls_test.py +0 -630
  170. test/config_test.py +0 -137
  171. test/conftest.py +0 -1420
  172. test/container_app_test.py +0 -32
  173. test/container_test.py +0 -1389
  174. test/cpu_test.py +0 -23
  175. test/decorator_test.py +0 -85
  176. test/deprecation_test.py +0 -34
  177. test/dict_test.py +0 -33
  178. test/e2e_test.py +0 -68
  179. test/error_test.py +0 -7
  180. test/function_serialization_test.py +0 -32
  181. test/function_test.py +0 -653
  182. test/function_utils_test.py +0 -101
  183. test/gpu_test.py +0 -159
  184. test/grpc_utils_test.py +0 -141
  185. test/helpers.py +0 -42
  186. test/image_test.py +0 -669
  187. test/live_reload_test.py +0 -80
  188. test/lookup_test.py +0 -70
  189. test/mdmd_test.py +0 -329
  190. test/mount_test.py +0 -162
  191. test/mounted_files_test.py +0 -329
  192. test/network_file_system_test.py +0 -181
  193. test/notebook_test.py +0 -66
  194. test/object_test.py +0 -41
  195. test/package_utils_test.py +0 -25
  196. test/queue_test.py +0 -97
  197. test/resolver_test.py +0 -58
  198. test/retries_test.py +0 -67
  199. test/runner_test.py +0 -85
  200. test/sandbox_test.py +0 -191
  201. test/schedule_test.py +0 -15
  202. test/scheduler_placement_test.py +0 -29
  203. test/secret_test.py +0 -78
  204. test/serialization_test.py +0 -42
  205. test/stub_composition_test.py +0 -10
  206. test/stub_test.py +0 -360
  207. test/test_asgi_wrapper.py +0 -234
  208. test/token_flow_test.py +0 -18
  209. test/traceback_test.py +0 -135
  210. test/tunnel_test.py +0 -29
  211. test/utils_test.py +0 -88
  212. test/version_test.py +0 -14
  213. test/volume_test.py +0 -341
  214. test/watcher_test.py +0 -30
  215. test/webhook_test.py +0 -146
  216. /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
  217. /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
  218. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
  219. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
  220. {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/mount.py CHANGED
@@ -4,25 +4,33 @@ import asyncio
4
4
  import concurrent.futures
5
5
  import dataclasses
6
6
  import os
7
+ import site
8
+ import sys
9
+ import sysconfig
7
10
  import time
8
11
  import typing
12
+ from collections.abc import AsyncGenerator
9
13
  from pathlib import Path, PurePosixPath
10
- from typing import AsyncGenerator, Callable, List, Optional, Tuple, Type, Union
14
+ from typing import Callable, Optional, Sequence, Union
11
15
 
12
- import aiostream
13
16
  from google.protobuf.message import Message
14
17
 
15
18
  import modal.exception
19
+ import modal.file_pattern_matcher
16
20
  from modal_proto import api_pb2
17
21
  from modal_version import __version__
18
22
 
19
23
  from ._resolver import Resolver
20
- from ._utils.async_utils import synchronize_api
24
+ from ._utils.async_utils import aclosing, async_map, synchronize_api
21
25
  from ._utils.blob_utils import FileUploadSpec, blob_upload_file, get_file_upload_spec_from_path
26
+ from ._utils.deprecation import deprecation_warning, renamed_parameter
22
27
  from ._utils.grpc_utils import retry_transient_errors
28
+ from ._utils.name_utils import check_object_name
23
29
  from ._utils.package_utils import get_module_mount_info
24
30
  from .client import _Client
25
31
  from .config import config, logger
32
+ from .exception import InvalidError, ModuleNotMountable
33
+ from .file_pattern_matcher import FilePatternMatcher
26
34
  from .object import _get_environment_name, _Object
27
35
 
28
36
  ROOT_DIR: PurePosixPath = PurePosixPath("/root")
@@ -32,14 +40,19 @@ MOUNT_PUT_FILE_CLIENT_TIMEOUT = 10 * 60 # 10 min max for transferring files
32
40
  #
33
41
  # These can be updated safely, but changes will trigger a rebuild for all images
34
42
  # that rely on `add_python()` in their constructor.
35
- PYTHON_STANDALONE_VERSIONS: typing.Dict[str, typing.Tuple[str, str]] = {
36
- "3.8": ("20230826", "3.8.17"),
43
+ PYTHON_STANDALONE_VERSIONS: dict[str, tuple[str, str]] = {
37
44
  "3.9": ("20230826", "3.9.18"),
38
45
  "3.10": ("20230826", "3.10.13"),
39
46
  "3.11": ("20230826", "3.11.5"),
40
47
  "3.12": ("20240107", "3.12.1"),
48
+ "3.13": ("20241008", "3.13.0"),
41
49
  }
42
50
 
51
+ MOUNT_DEPRECATION_MESSAGE_PATTERN = """modal.Mount usage will soon be deprecated.
52
+
53
+ Use {replacement} instead, which is functionally and performance-wise equivalent.
54
+ """
55
+
43
56
 
44
57
  def client_mount_name() -> str:
45
58
  """Get the deployed name of the client package mount."""
@@ -54,7 +67,8 @@ def python_standalone_mount_name(version: str) -> str:
54
67
  libc = "gnu"
55
68
  if version not in PYTHON_STANDALONE_VERSIONS:
56
69
  raise modal.exception.InvalidError(
57
- f"Unsupported standalone python version: {version}, supported values are {list(PYTHON_STANDALONE_VERSIONS.keys())}"
70
+ f"Unsupported standalone python version: {version!r}, supported values are "
71
+ f"{list(PYTHON_STANDALONE_VERSIONS)}"
58
72
  )
59
73
  if libc != "gnu":
60
74
  raise modal.exception.InvalidError(f"Unsupported libc identifier: {libc}")
@@ -68,21 +82,21 @@ class _MountEntry(metaclass=abc.ABCMeta):
68
82
  ...
69
83
 
70
84
  @abc.abstractmethod
71
- def get_files_to_upload(self) -> typing.Iterator[Tuple[Path, str]]:
85
+ def get_files_to_upload(self) -> typing.Iterator[tuple[Path, str]]:
72
86
  ...
73
87
 
74
88
  @abc.abstractmethod
75
- def watch_entry(self) -> Tuple[Path, Path]:
89
+ def watch_entry(self) -> tuple[Path, Path]:
76
90
  ...
77
91
 
78
92
  @abc.abstractmethod
79
- def top_level_paths(self) -> List[Tuple[Path, PurePosixPath]]:
93
+ def top_level_paths(self) -> list[tuple[Path, PurePosixPath]]:
80
94
  ...
81
95
 
82
96
 
83
- def _select_files(entries: List[_MountEntry]) -> List[Tuple[Path, PurePosixPath]]:
97
+ def _select_files(entries: list[_MountEntry]) -> list[tuple[Path, PurePosixPath]]:
84
98
  # TODO: make this async
85
- all_files: typing.Set[Tuple[Path, PurePosixPath]] = set()
99
+ all_files: set[tuple[Path, PurePosixPath]] = set()
86
100
  for entry in entries:
87
101
  all_files |= set(entry.get_files_to_upload())
88
102
  return list(all_files)
@@ -97,7 +111,7 @@ class _MountFile(_MountEntry):
97
111
  return str(self.local_file)
98
112
 
99
113
  def get_files_to_upload(self):
100
- local_file = self.local_file.expanduser().absolute()
114
+ local_file = self.local_file.resolve()
101
115
  if not local_file.exists():
102
116
  raise FileNotFoundError(local_file)
103
117
 
@@ -108,7 +122,7 @@ class _MountFile(_MountEntry):
108
122
  safe_path = self.local_file.expanduser().absolute()
109
123
  return safe_path.parent, safe_path
110
124
 
111
- def top_level_paths(self) -> List[Tuple[Path, PurePosixPath]]:
125
+ def top_level_paths(self) -> list[tuple[Path, PurePosixPath]]:
112
126
  return [(self.local_file, self.remote_path)]
113
127
 
114
128
 
@@ -116,13 +130,15 @@ class _MountFile(_MountEntry):
116
130
  class _MountDir(_MountEntry):
117
131
  local_dir: Path
118
132
  remote_path: PurePosixPath
119
- condition: Callable[[str], bool]
133
+ ignore: Callable[[Path], bool]
120
134
  recursive: bool
121
135
 
122
136
  def description(self):
123
137
  return str(self.local_dir.expanduser().absolute())
124
138
 
125
139
  def get_files_to_upload(self):
140
+ # we can't use .resolve() eagerly here since that could end up "renaming" symlinked files
141
+ # see test_mount_directory_with_symlinked_file
126
142
  local_dir = self.local_dir.expanduser().absolute()
127
143
 
128
144
  if not local_dir.exists():
@@ -137,25 +153,48 @@ class _MountDir(_MountEntry):
137
153
  gen = (dir_entry.path for dir_entry in os.scandir(local_dir) if dir_entry.is_file())
138
154
 
139
155
  for local_filename in gen:
140
- if self.condition(local_filename):
141
- local_relpath = Path(local_filename).expanduser().absolute().relative_to(local_dir)
156
+ local_path = Path(local_filename)
157
+ if not self.ignore(local_path):
158
+ local_relpath = local_path.expanduser().absolute().relative_to(local_dir)
142
159
  mount_path = self.remote_path / local_relpath.as_posix()
143
- yield local_filename, mount_path
160
+ yield local_path.resolve(), mount_path
144
161
 
145
162
  def watch_entry(self):
146
163
  return self.local_dir.resolve().expanduser(), None
147
164
 
148
- def top_level_paths(self) -> List[Tuple[Path, PurePosixPath]]:
165
+ def top_level_paths(self) -> list[tuple[Path, PurePosixPath]]:
149
166
  return [(self.local_dir, self.remote_path)]
150
167
 
151
168
 
152
- def module_mount_condition(f: str):
153
- path = Path(f)
154
- if path.suffix == ".pyc":
155
- return False
156
- if any(p.name.startswith(".") or p.name == "__pycache__" for p in path.parents):
157
- return False
158
- return True
169
+ def module_mount_condition(module_base: Path):
170
+ SKIP_BYTECODE = True # hard coded for now
171
+ SKIP_DOT_PREFIXED = True
172
+
173
+ def condition(f: str):
174
+ path = Path(f)
175
+ if SKIP_BYTECODE and path.suffix == ".pyc":
176
+ return False
177
+
178
+ # Check parent dir names to see if file should be included,
179
+ # but ignore dir names above root of mounted module:
180
+ # /a/.venv/site-packages/mymod/foo.py should be included by default
181
+ # /a/my_mod/.config/foo.py should *not* be included by default
182
+ while path != module_base and path != path.parent:
183
+ if SKIP_BYTECODE and path.name == "__pycache__":
184
+ return False
185
+
186
+ if SKIP_DOT_PREFIXED and path.name.startswith("."):
187
+ return False
188
+
189
+ path = path.parent
190
+
191
+ return True
192
+
193
+ return condition
194
+
195
+
196
+ def module_mount_ignore_condition(module_base: Path):
197
+ return lambda f: not module_mount_condition(module_base)(str(f))
159
198
 
160
199
 
161
200
  @dataclasses.dataclass
@@ -166,12 +205,12 @@ class _MountedPythonModule(_MountEntry):
166
205
 
167
206
  module_name: str
168
207
  remote_dir: Union[PurePosixPath, str] = ROOT_DIR.as_posix() # cast needed here for type stub generation...
169
- condition: typing.Optional[typing.Callable[[str], bool]] = None
208
+ ignore: Optional[Callable[[Path], bool]] = None
170
209
 
171
210
  def description(self) -> str:
172
211
  return f"PythonPackage:{self.module_name}"
173
212
 
174
- def _proxy_entries(self) -> List[_MountEntry]:
213
+ def _proxy_entries(self) -> list[_MountEntry]:
175
214
  mount_infos = get_module_mount_info(self.module_name)
176
215
  entries = []
177
216
  for mount_info in mount_infos:
@@ -180,9 +219,9 @@ class _MountedPythonModule(_MountEntry):
180
219
  remote_dir = PurePosixPath(self.remote_dir, *self.module_name.split("."))
181
220
  entries.append(
182
221
  _MountDir(
183
- Path(base_path),
222
+ base_path,
184
223
  remote_path=remote_dir,
185
- condition=self.condition or module_mount_condition,
224
+ ignore=self.ignore or module_mount_ignore_condition(base_path),
186
225
  recursive=True,
187
226
  )
188
227
  )
@@ -197,16 +236,16 @@ class _MountedPythonModule(_MountEntry):
197
236
  )
198
237
  return entries
199
238
 
200
- def get_files_to_upload(self) -> typing.Iterator[Tuple[Path, str]]:
239
+ def get_files_to_upload(self) -> typing.Iterator[tuple[Path, str]]:
201
240
  for entry in self._proxy_entries():
202
241
  yield from entry.get_files_to_upload()
203
242
 
204
- def watch_entry(self) -> Tuple[Path, Path]:
243
+ def watch_entry(self) -> tuple[Path, Path]:
205
244
  for entry in self._proxy_entries():
206
245
  # TODO: fix watch for mounts of multi-path packages
207
246
  return entry.watch_entry()
208
247
 
209
- def top_level_paths(self) -> List[Tuple[Path, PurePosixPath]]:
248
+ def top_level_paths(self) -> list[tuple[Path, PurePosixPath]]:
210
249
  paths = []
211
250
  for sub in self._proxy_entries():
212
251
  paths.extend(sub.top_level_paths())
@@ -227,9 +266,9 @@ class _Mount(_Object, type_prefix="mo"):
227
266
  ```python
228
267
  import modal
229
268
  import os
230
- stub = modal.Stub()
269
+ app = modal.App()
231
270
 
232
- @stub.function(mounts=[modal.Mount.from_local_dir("~/foo", remote_path="/root/foo")])
271
+ @app.function(mounts=[modal.Mount.from_local_dir("~/foo", remote_path="/root/foo")])
233
272
  def f():
234
273
  # `/root/foo` has the contents of `~/foo`.
235
274
  print(os.listdir("/root/foo/"))
@@ -239,14 +278,14 @@ class _Mount(_Object, type_prefix="mo"):
239
278
  the file's contents to skip uploading files that have been uploaded before.
240
279
  """
241
280
 
242
- _entries: Optional[List[_MountEntry]] = None
281
+ _entries: Optional[list[_MountEntry]] = None
243
282
  _deployment_name: Optional[str] = None
244
283
  _namespace: Optional[int] = None
245
284
  _environment_name: Optional[str] = None
246
285
  _content_checksum_sha256_hex: Optional[str] = None
247
286
 
248
287
  @staticmethod
249
- def _new(entries: List[_MountEntry] = []) -> "_Mount":
288
+ def _new(entries: list[_MountEntry] = []) -> "_Mount":
250
289
  rep = f"Mount({entries})"
251
290
 
252
291
  async def mount_content_deduplication_key():
@@ -275,10 +314,10 @@ class _Mount(_Object, type_prefix="mo"):
275
314
  assert isinstance(handle_metadata, api_pb2.MountHandleMetadata)
276
315
  self._content_checksum_sha256_hex = handle_metadata.content_checksum_sha256_hex
277
316
 
278
- def _top_level_paths(self) -> List[Tuple[Path, PurePosixPath]]:
317
+ def _top_level_paths(self) -> list[tuple[Path, PurePosixPath]]:
279
318
  # Returns [(local_absolute_path, remote_path), ...] for all top level entries in the Mount
280
319
  # Used to determine if a package mount is installed in a sys directory or not
281
- res: List[Tuple[Path, PurePosixPath]] = []
320
+ res: list[tuple[Path, PurePosixPath]] = []
282
321
  for entry in self.entries:
283
322
  res.extend(entry.top_level_paths())
284
323
  return res
@@ -289,13 +328,29 @@ class _Mount(_Object, type_prefix="mo"):
289
328
  # we can't rely on it to be set. Let's clean this up later.
290
329
  return getattr(self, "_is_local", False)
291
330
 
331
+ @staticmethod
332
+ def _add_local_dir(
333
+ local_path: Path,
334
+ remote_path: PurePosixPath,
335
+ ignore: Callable[[Path], bool] = modal.file_pattern_matcher._NOTHING,
336
+ ):
337
+ return _Mount._new()._extend(
338
+ _MountDir(
339
+ local_dir=local_path,
340
+ ignore=ignore,
341
+ remote_path=remote_path,
342
+ recursive=True,
343
+ ),
344
+ )
345
+
292
346
  def add_local_dir(
293
347
  self,
294
348
  local_path: Union[str, Path],
295
349
  *,
296
350
  # Where the directory is placed within in the mount
297
351
  remote_path: Union[str, PurePosixPath, None] = None,
298
- # Filter function for file selection; defaults to including all files
352
+ # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion.
353
+ # Defaults to including all files.
299
354
  condition: Optional[Callable[[str], bool]] = None,
300
355
  # add files from subdirectories as well
301
356
  recursive: bool = True,
@@ -314,10 +369,13 @@ class _Mount(_Object, type_prefix="mo"):
314
369
 
315
370
  condition = include_all
316
371
 
372
+ def converted_condition(path: Path) -> bool:
373
+ return not condition(str(path))
374
+
317
375
  return self._extend(
318
376
  _MountDir(
319
377
  local_dir=local_path,
320
- condition=condition,
378
+ ignore=converted_condition,
321
379
  remote_path=remote_path,
322
380
  recursive=recursive,
323
381
  ),
@@ -329,7 +387,8 @@ class _Mount(_Object, type_prefix="mo"):
329
387
  *,
330
388
  # Where the directory is placed within in the mount
331
389
  remote_path: Union[str, PurePosixPath, None] = None,
332
- # Filter function for file selection - default all files
390
+ # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion.
391
+ # Defaults to including all files.
333
392
  condition: Optional[Callable[[str], bool]] = None,
334
393
  # add files from subdirectories as well
335
394
  recursive: bool = True,
@@ -347,12 +406,31 @@ class _Mount(_Object, type_prefix="mo"):
347
406
  )
348
407
  ```
349
408
  """
409
+ deprecation_warning(
410
+ (2024, 1, 8), MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_dir"), pending=True
411
+ )
412
+ return _Mount._from_local_dir(local_path, remote_path=remote_path, condition=condition, recursive=recursive)
413
+
414
+ @staticmethod
415
+ def _from_local_dir(
416
+ local_path: Union[str, Path],
417
+ *,
418
+ # Where the directory is placed within in the mount
419
+ remote_path: Union[str, PurePosixPath, None] = None,
420
+ # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion.
421
+ # Defaults to including all files.
422
+ condition: Optional[Callable[[str], bool]] = None,
423
+ # add files from subdirectories as well
424
+ recursive: bool = True,
425
+ ) -> "_Mount":
350
426
  return _Mount._new().add_local_dir(
351
427
  local_path, remote_path=remote_path, condition=condition, recursive=recursive
352
428
  )
353
429
 
354
430
  def add_local_file(
355
- self, local_path: Union[str, Path], remote_path: Union[str, PurePosixPath, None] = None
431
+ self,
432
+ local_path: Union[str, Path],
433
+ remote_path: Union[str, PurePosixPath, None] = None,
356
434
  ) -> "_Mount":
357
435
  """
358
436
  Add a local file to the `Mount` object.
@@ -383,24 +461,32 @@ class _Mount(_Object, type_prefix="mo"):
383
461
  )
384
462
  ```
385
463
  """
464
+ deprecation_warning(
465
+ (2024, 1, 8), MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_file"), pending=True
466
+ )
467
+ return _Mount._from_local_file(local_path, remote_path)
468
+
469
+ @staticmethod
470
+ def _from_local_file(local_path: Union[str, Path], remote_path: Union[str, PurePosixPath, None] = None) -> "_Mount":
386
471
  return _Mount._new().add_local_file(local_path, remote_path=remote_path)
387
472
 
388
473
  @staticmethod
389
- def _description(entries: List[_MountEntry]) -> str:
474
+ def _description(entries: list[_MountEntry]) -> str:
390
475
  local_contents = [e.description() for e in entries]
391
476
  return ", ".join(local_contents)
392
477
 
393
478
  @staticmethod
394
- async def _get_files(entries: List[_MountEntry]) -> AsyncGenerator[FileUploadSpec, None]:
479
+ async def _get_files(entries: list[_MountEntry]) -> AsyncGenerator[FileUploadSpec, None]:
395
480
  loop = asyncio.get_event_loop()
396
481
  with concurrent.futures.ThreadPoolExecutor() as exe:
397
482
  all_files = await loop.run_in_executor(exe, _select_files, entries)
398
483
 
399
484
  futs = []
400
485
  for local_filename, remote_filename in all_files:
486
+ logger.debug(f"Mounting {local_filename} as {remote_filename}")
401
487
  futs.append(loop.run_in_executor(exe, get_file_upload_spec_from_path, local_filename, remote_filename))
402
488
 
403
- logger.debug(f"Computing checksums for {len(futs)} files using {exe._max_workers} workers")
489
+ logger.debug(f"Computing checksums for {len(futs)} files using {exe._max_workers} worker threads")
404
490
  for fut in asyncio.as_completed(futs):
405
491
  try:
406
492
  yield await fut
@@ -413,21 +499,20 @@ class _Mount(_Object, type_prefix="mo"):
413
499
  resolver: Resolver,
414
500
  existing_object_id: Optional[str],
415
501
  ):
416
- # Run a threadpool to compute hash values, and use concurrent coroutines to register files.
417
- t0 = time.time()
418
- n_concurrent_uploads = 16
502
+ t0 = time.monotonic()
419
503
 
420
- n_files = 0
421
- uploaded_hashes: set[str] = set()
422
- total_bytes = 0
504
+ # Asynchronously list and checksum files with a thread pool, then upload them concurrently.
505
+ n_seen, n_finished = 0, 0
506
+ total_uploads, total_bytes = 0, 0
507
+ accounted_hashes: set[str] = set()
423
508
  message_label = _Mount._description(self._entries)
509
+ blob_upload_concurrency = asyncio.Semaphore(16) # Limit uploads of large files.
424
510
  status_row = resolver.add_status_row()
425
511
 
426
512
  async def _put_file(file_spec: FileUploadSpec) -> api_pb2.MountFile:
427
- nonlocal n_files, uploaded_hashes, total_bytes
428
- status_row.message(
429
- f"Creating mount {message_label}: Uploaded {len(uploaded_hashes)}/{n_files} inspected files"
430
- )
513
+ nonlocal n_seen, n_finished, total_uploads, total_bytes
514
+ n_seen += 1
515
+ status_row.message(f"Creating mount {message_label}: Uploaded {n_finished}/{n_seen} files")
431
516
 
432
517
  remote_filename = file_spec.mount_filename
433
518
  mount_file = api_pb2.MountFile(
@@ -436,23 +521,28 @@ class _Mount(_Object, type_prefix="mo"):
436
521
  mode=file_spec.mode,
437
522
  )
438
523
 
439
- if file_spec.sha256_hex in uploaded_hashes:
524
+ if file_spec.sha256_hex in accounted_hashes:
525
+ n_finished += 1
440
526
  return mount_file
441
527
 
442
528
  request = api_pb2.MountPutFileRequest(sha256_hex=file_spec.sha256_hex)
529
+ accounted_hashes.add(file_spec.sha256_hex)
443
530
  response = await retry_transient_errors(resolver.client.stub.MountPutFile, request, base_delay=1)
444
531
 
445
- n_files += 1
446
532
  if response.exists:
533
+ n_finished += 1
447
534
  return mount_file
448
535
 
449
- uploaded_hashes.add(file_spec.sha256_hex)
536
+ total_uploads += 1
450
537
  total_bytes += file_spec.size
451
538
 
452
539
  if file_spec.use_blob:
453
540
  logger.debug(f"Creating blob file for {file_spec.source_description} ({file_spec.size} bytes)")
454
- with file_spec.source() as fp:
455
- blob_id = await blob_upload_file(fp, resolver.client.stub)
541
+ async with blob_upload_concurrency:
542
+ with file_spec.source() as fp:
543
+ blob_id = await blob_upload_file(
544
+ fp, resolver.client.stub, sha256_hex=file_spec.sha256_hex, md5_hex=file_spec.md5_hex
545
+ )
456
546
  logger.debug(f"Uploading blob file {file_spec.source_description} as {remote_filename}")
457
547
  request2 = api_pb2.MountPutFileRequest(data_blob_id=blob_id, sha256_hex=file_spec.sha256_hex)
458
548
  else:
@@ -465,24 +555,25 @@ class _Mount(_Object, type_prefix="mo"):
465
555
  while time.monotonic() - start_time < MOUNT_PUT_FILE_CLIENT_TIMEOUT:
466
556
  response = await retry_transient_errors(resolver.client.stub.MountPutFile, request2, base_delay=1)
467
557
  if response.exists:
558
+ n_finished += 1
468
559
  return mount_file
469
560
 
470
561
  raise modal.exception.MountUploadTimeoutError(f"Mounting of {file_spec.source_description} timed out")
471
562
 
472
- logger.debug(f"Uploading mount using {n_concurrent_uploads} uploads")
473
-
474
- # Create async generator
475
- files_stream = aiostream.stream.iterate(_Mount._get_files(self._entries))
476
-
477
- # Upload files
478
- uploads_stream = aiostream.stream.map(files_stream, _put_file, task_limit=n_concurrent_uploads)
479
- files: List[api_pb2.MountFile] = await aiostream.stream.list(uploads_stream)
563
+ # Upload files, or check if they already exist.
564
+ n_concurrent_uploads = 512
565
+ files: list[api_pb2.MountFile] = []
566
+ async with aclosing(
567
+ async_map(_Mount._get_files(self._entries), _put_file, concurrency=n_concurrent_uploads)
568
+ ) as stream:
569
+ async for file in stream:
570
+ files.append(file)
480
571
 
481
572
  if not files:
482
573
  logger.warning(f"Mount of '{message_label}' is empty.")
483
574
 
484
- # Build mounts
485
- status_row.message(f"Creating mount {message_label}: Building mount")
575
+ # Build the mount.
576
+ status_row.message(f"Creating mount {message_label}: Finalizing index of {len(files)} files")
486
577
  if self._deployment_name:
487
578
  req = api_pb2.MountGetOrCreateRequest(
488
579
  deployment_name=self._deployment_name,
@@ -491,26 +582,38 @@ class _Mount(_Object, type_prefix="mo"):
491
582
  object_creation_type=api_pb2.OBJECT_CREATION_TYPE_CREATE_FAIL_IF_EXISTS,
492
583
  files=files,
493
584
  )
494
- else:
585
+ elif resolver.app_id is not None:
495
586
  req = api_pb2.MountGetOrCreateRequest(
496
587
  object_creation_type=api_pb2.OBJECT_CREATION_TYPE_ANONYMOUS_OWNED_BY_APP,
497
588
  files=files,
498
589
  app_id=resolver.app_id,
499
590
  )
591
+ else:
592
+ req = api_pb2.MountGetOrCreateRequest(
593
+ object_creation_type=api_pb2.OBJECT_CREATION_TYPE_EPHEMERAL,
594
+ files=files,
595
+ environment_name=resolver.environment_name,
596
+ )
597
+
500
598
  resp = await retry_transient_errors(resolver.client.stub.MountGetOrCreate, req, base_delay=1)
501
599
  status_row.finish(f"Created mount {message_label}")
502
600
 
503
- logger.debug(f"Uploaded {len(uploaded_hashes)}/{n_files} files and {total_bytes} bytes in {time.time() - t0}s")
601
+ logger.debug(f"Uploaded {total_uploads} new files and {total_bytes} bytes in {time.monotonic() - t0}s")
504
602
  self._hydrate(resp.mount_id, resolver.client, resp.handle_metadata)
505
603
 
506
604
  @staticmethod
507
605
  def from_local_python_packages(
508
606
  *module_names: str,
509
607
  remote_dir: Union[str, PurePosixPath] = ROOT_DIR.as_posix(),
608
+ # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion.
609
+ # Defaults to including all files.
510
610
  condition: Optional[Callable[[str], bool]] = None,
611
+ ignore: Optional[Union[Sequence[str], Callable[[Path], bool]]] = None,
511
612
  ) -> "_Mount":
512
- """Returns a `modal.Mount` that makes local modules listed in `module_names` available inside the container.
513
- This works by mounting the local path of each module's package to a directory inside the container that's on `PYTHONPATH`.
613
+ """
614
+ Returns a `modal.Mount` that makes local modules listed in `module_names` available inside the container.
615
+ This works by mounting the local path of each module's package to a directory inside the container
616
+ that's on `PYTHONPATH`.
514
617
 
515
618
  **Usage**
516
619
 
@@ -518,36 +621,67 @@ class _Mount(_Object, type_prefix="mo"):
518
621
  import modal
519
622
  import my_local_module
520
623
 
521
- stub = modal.Stub()
624
+ app = modal.App()
522
625
 
523
- @stub.function(mounts=[
626
+ @app.function(mounts=[
524
627
  modal.Mount.from_local_python_packages("my_local_module", "my_other_module"),
525
628
  ])
526
629
  def f():
527
630
  my_local_module.do_stuff()
528
631
  ```
529
632
  """
633
+ deprecation_warning(
634
+ (2024, 1, 8),
635
+ MOUNT_DEPRECATION_MESSAGE_PATTERN.format(replacement="image.add_local_python_source"),
636
+ pending=True,
637
+ )
638
+ return _Mount._from_local_python_packages(
639
+ *module_names, remote_dir=remote_dir, condition=condition, ignore=ignore
640
+ )
530
641
 
642
+ @staticmethod
643
+ def _from_local_python_packages(
644
+ *module_names: str,
645
+ remote_dir: Union[str, PurePosixPath] = ROOT_DIR.as_posix(),
646
+ # Predicate filter function for file selection, which should accept a filepath and return `True` for inclusion.
647
+ # Defaults to including all files.
648
+ condition: Optional[Callable[[str], bool]] = None,
649
+ ignore: Optional[Union[Sequence[str], Callable[[Path], bool]]] = None,
650
+ ) -> "_Mount":
531
651
  # Don't re-run inside container.
532
652
 
653
+ if condition is not None:
654
+ if ignore is not None:
655
+ raise InvalidError("Cannot specify both `ignore` and `condition`")
656
+
657
+ def converted_condition(path: Path) -> bool:
658
+ return not condition(str(path))
659
+
660
+ ignore = converted_condition
661
+ elif isinstance(ignore, list):
662
+ ignore = FilePatternMatcher(*ignore)
663
+
533
664
  mount = _Mount._new()
534
- from modal import is_local
665
+ from ._runtime.execution_context import is_local
535
666
 
536
667
  if not is_local():
537
668
  return mount # empty/non-mountable mount in case it's used from within a container
538
669
  for module_name in module_names:
539
- mount = mount._extend(_MountedPythonModule(module_name, remote_dir, condition))
670
+ mount = mount._extend(_MountedPythonModule(module_name, remote_dir, ignore))
540
671
  return mount
541
672
 
542
673
  @staticmethod
674
+ @renamed_parameter((2024, 12, 18), "label", "name")
543
675
  def from_name(
544
- label: str,
676
+ name: str,
545
677
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
546
678
  environment_name: Optional[str] = None,
547
679
  ) -> "_Mount":
680
+ """mdmd:hidden"""
681
+
548
682
  async def _load(provider: _Mount, resolver: Resolver, existing_object_id: Optional[str]):
549
683
  req = api_pb2.MountGetOrCreateRequest(
550
- deployment_name=label,
684
+ deployment_name=name,
551
685
  namespace=namespace,
552
686
  environment_name=_get_environment_name(environment_name, resolver),
553
687
  )
@@ -557,14 +691,16 @@ class _Mount(_Object, type_prefix="mo"):
557
691
  return _Mount._from_loader(_load, "Mount()")
558
692
 
559
693
  @classmethod
694
+ @renamed_parameter((2024, 12, 18), "label", "name")
560
695
  async def lookup(
561
- cls: Type["_Mount"],
562
- label: str,
696
+ cls: type["_Mount"],
697
+ name: str,
563
698
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
564
699
  client: Optional[_Client] = None,
565
700
  environment_name: Optional[str] = None,
566
701
  ) -> "_Mount":
567
- obj = _Mount.from_name(label, namespace=namespace, environment_name=environment_name)
702
+ """mdmd:hidden"""
703
+ obj = _Mount.from_name(name, namespace=namespace, environment_name=environment_name)
568
704
  if client is None:
569
705
  client = await _Client.from_env()
570
706
  resolver = Resolver(client=client)
@@ -577,13 +713,15 @@ class _Mount(_Object, type_prefix="mo"):
577
713
  namespace=api_pb2.DEPLOYMENT_NAMESPACE_WORKSPACE,
578
714
  environment_name: Optional[str] = None,
579
715
  client: Optional[_Client] = None,
580
- ) -> "_Mount":
716
+ ) -> None:
717
+ check_object_name(deployment_name, "Mount")
718
+ environment_name = _get_environment_name(environment_name, resolver=None)
581
719
  self._deployment_name = deployment_name
582
720
  self._namespace = namespace
583
721
  self._environment_name = environment_name
584
722
  if client is None:
585
723
  client = await _Client.from_env()
586
- resolver = Resolver(client=client)
724
+ resolver = Resolver(client=client, environment_name=environment_name)
587
725
  await resolver.load(self)
588
726
 
589
727
  def _get_metadata(self) -> api_pb2.MountHandleMetadata:
@@ -603,25 +741,27 @@ def _create_client_mount():
603
741
  import modal
604
742
 
605
743
  # Get the base_path because it also contains `modal_proto`.
606
- base_path, _ = os.path.split(modal.__path__[0])
607
-
608
- # TODO(erikbern): this is incredibly dumb, but we only want to include packages that start with "modal"
609
- # TODO(erikbern): merge functionality with function_utils._is_modal_path
610
- prefix = os.path.join(base_path, "modal")
611
-
612
- def condition(arg):
613
- return module_mount_condition(arg) and arg.startswith(prefix)
614
-
615
- return (
616
- _Mount.from_local_dir(base_path, remote_path="/pkg/", condition=condition, recursive=True)
617
- # Mount synchronicity, so version changes don't trigger image rebuilds for users.
618
- .add_local_dir(
619
- synchronicity.__path__[0],
620
- remote_path="/pkg/synchronicity",
621
- condition=module_mount_condition,
744
+ modal_parent_dir, _ = os.path.split(modal.__path__[0])
745
+ client_mount = _Mount._new()
746
+
747
+ for pkg_name in MODAL_PACKAGES:
748
+ package_base_path = Path(modal_parent_dir) / pkg_name
749
+ client_mount = client_mount.add_local_dir(
750
+ package_base_path,
751
+ remote_path=f"/pkg/{pkg_name}",
752
+ condition=module_mount_condition(package_base_path),
622
753
  recursive=True,
623
754
  )
755
+
756
+ # Mount synchronicity, so version changes don't trigger image rebuilds for users.
757
+ synchronicity_base_path = Path(synchronicity.__path__[0])
758
+ client_mount = client_mount.add_local_dir(
759
+ synchronicity_base_path,
760
+ remote_path="/pkg/synchronicity",
761
+ condition=module_mount_condition(synchronicity_base_path),
762
+ recursive=True,
624
763
  )
764
+ return client_mount
625
765
 
626
766
 
627
767
  create_client_mount = synchronize_api(_create_client_mount)
@@ -633,3 +773,76 @@ def _get_client_mount():
633
773
  return _create_client_mount()
634
774
  else:
635
775
  return _Mount.from_name(client_mount_name(), namespace=api_pb2.DEPLOYMENT_NAMESPACE_GLOBAL)
776
+
777
+
778
+ SYS_PREFIXES = {
779
+ Path(p)
780
+ for p in (
781
+ sys.prefix,
782
+ sys.base_prefix,
783
+ sys.exec_prefix,
784
+ sys.base_exec_prefix,
785
+ *sysconfig.get_paths().values(),
786
+ *site.getsitepackages(),
787
+ site.getusersitepackages(),
788
+ )
789
+ }
790
+
791
+
792
+ SYS_PREFIXES |= {p.resolve() for p in SYS_PREFIXES}
793
+
794
+ MODAL_PACKAGES = ["modal", "modal_proto", "modal_version"]
795
+
796
+
797
+ def _is_modal_path(remote_path: PurePosixPath):
798
+ path_prefix = remote_path.parts[:3]
799
+ remote_python_paths = [("/", "root"), ("/", "pkg")]
800
+ for base in remote_python_paths:
801
+ is_modal_path = path_prefix in [base + (mod,) for mod in MODAL_PACKAGES] or path_prefix == base + (
802
+ "synchronicity",
803
+ )
804
+ if is_modal_path:
805
+ return True
806
+ return False
807
+
808
+
809
+ def get_auto_mounts() -> list[_Mount]:
810
+ """mdmd:hidden
811
+
812
+ Auto-mount local modules that have been imported in global scope.
813
+ This may or may not include the "entrypoint" of the function as well, depending on how modal is invoked
814
+ Note: sys.modules may change during the iteration
815
+ """
816
+ auto_mounts = []
817
+ top_level_modules = []
818
+ skip_prefixes = set()
819
+ for name, module in sorted(sys.modules.items(), key=lambda kv: len(kv[0])):
820
+ parent = name.rsplit(".")[0]
821
+ if parent and parent in skip_prefixes:
822
+ skip_prefixes.add(name)
823
+ continue
824
+ skip_prefixes.add(name)
825
+ top_level_modules.append((name, module))
826
+
827
+ for module_name, module in top_level_modules:
828
+ if module_name.startswith("__"):
829
+ # skip "built in" modules like __main__ and __mp_main__
830
+ # the running function's main file should be included anyway
831
+ continue
832
+
833
+ try:
834
+ # at this point we don't know if the sys.modules module should be mounted or not
835
+ potential_mount = _Mount._from_local_python_packages(module_name)
836
+ mount_paths = potential_mount._top_level_paths()
837
+ except ModuleNotMountable:
838
+ # this typically happens if the module is a built-in, has binary components or doesn't exist
839
+ continue
840
+
841
+ for local_path, remote_path in mount_paths:
842
+ if any(local_path.is_relative_to(p) for p in SYS_PREFIXES) or _is_modal_path(remote_path):
843
+ # skip any module that has paths in SYS_PREFIXES, or would overwrite the modal Package in the container
844
+ break
845
+ else:
846
+ auto_mounts.append(potential_mount)
847
+
848
+ return auto_mounts