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
@@ -1,37 +1,33 @@
1
1
  # Copyright Modal Labs 2022
2
+ import asyncio
2
3
  import inspect
3
4
  import os
4
- import site
5
- import sys
6
- import sysconfig
7
- import typing
8
- from collections import deque
5
+ from collections.abc import AsyncGenerator
9
6
  from enum import Enum
10
7
  from pathlib import Path, PurePosixPath
11
- from typing import Callable, List, Optional, Set, Type
8
+ from typing import Any, Callable, Literal, Optional
12
9
 
10
+ from grpclib import GRPCError
11
+ from grpclib.exceptions import StreamTerminatedError
12
+ from synchronicity.exceptions import UserCodeException
13
+
14
+ import modal_proto
13
15
  from modal_proto import api_pb2
14
16
 
15
- from .._serialization import serialize
17
+ from .._serialization import deserialize, deserialize_data_format, serialize
18
+ from .._traceback import append_modal_tb
16
19
  from ..config import config, logger
17
- from ..exception import InvalidError, ModuleNotMountable
18
- from ..mount import ROOT_DIR, _Mount
19
- from ..object import Object
20
-
21
- SYS_PREFIXES = {
22
- Path(p)
23
- for p in (
24
- sys.prefix,
25
- sys.base_prefix,
26
- sys.exec_prefix,
27
- sys.base_exec_prefix,
28
- *sysconfig.get_paths().values(),
29
- *site.getsitepackages(),
30
- site.getusersitepackages(),
31
- )
32
- }
33
-
34
- SYS_PREFIXES |= {p.resolve() for p in SYS_PREFIXES}
20
+ from ..exception import (
21
+ DeserializationError,
22
+ ExecutionError,
23
+ FunctionTimeoutError,
24
+ InternalFailure,
25
+ InvalidError,
26
+ RemoteError,
27
+ )
28
+ from ..mount import ROOT_DIR, _is_modal_path, _Mount
29
+ from .blob_utils import MAX_OBJECT_SIZE_BYTES, blob_download, blob_upload
30
+ from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
35
31
 
36
32
 
37
33
  class FunctionInfoType(Enum):
@@ -41,6 +37,13 @@ class FunctionInfoType(Enum):
41
37
  NOTEBOOK = "notebook"
42
38
 
43
39
 
40
+ # TODO(elias): Add support for quoted/str annotations
41
+ CLASS_PARAM_TYPE_MAP: dict[type, tuple["api_pb2.ParameterType.ValueType", str]] = {
42
+ str: (api_pb2.PARAM_TYPE_STRING, "string_default"),
43
+ int: (api_pb2.PARAM_TYPE_INT, "int_default"),
44
+ }
45
+
46
+
44
47
  class LocalFunctionError(InvalidError):
45
48
  """Raised if a function declared in a non-global scope is used in an impermissible way"""
46
49
 
@@ -60,23 +63,25 @@ def entrypoint_only_package_mount_condition(entrypoint_file):
60
63
  return inner
61
64
 
62
65
 
63
- def _is_modal_path(remote_path: PurePosixPath):
64
- path_prefix = remote_path.parts[:3]
65
- remote_python_paths = [("/", "root"), ("/", "pkg")]
66
- for base in remote_python_paths:
67
- is_modal_path = path_prefix in [
68
- base + ("modal",),
69
- base + ("modal_proto",),
70
- base + ("modal_version",),
71
- base + ("synchronicity",),
72
- ]
73
- if is_modal_path:
74
- return True
75
- return False
66
+ def is_global_object(object_qual_name: str):
67
+ return "<locals>" not in object_qual_name.split(".")
68
+
76
69
 
70
+ def is_method_fn(object_qual_name: str):
71
+ # methods have names like Cls.foo.
72
+ if "<locals>" in object_qual_name:
73
+ # functions can be nested in multiple local scopes.
74
+ rest = object_qual_name.split("<locals>.")[-1]
75
+ return len(rest.split(".")) > 1
76
+ return len(object_qual_name.split(".")) > 1
77
77
 
78
- def is_global_function(function_qual_name):
79
- return "<locals>" not in function_qual_name.split(".")
78
+
79
+ def is_top_level_function(f: Callable) -> bool:
80
+ """Returns True if this function is defined in global scope.
81
+
82
+ Returns False if this function is locally scoped (including on a class).
83
+ """
84
+ return f.__name__ == f.__qualname__
80
85
 
81
86
 
82
87
  def is_async(function):
@@ -86,6 +91,8 @@ def is_async(function):
86
91
  # coerce the type. For now let's make a determination based on inspecting the function definition.
87
92
  # This sometimes isn't correct, since a "vanilla" Python function can return a coroutine if it
88
93
  # wraps async code or similar. Let's revisit this shortly.
94
+ if inspect.ismethod(function):
95
+ function = function.__func__ # inspect the underlying function
89
96
  if inspect.iscoroutinefunction(function) or inspect.isasyncgenfunction(function):
90
97
  return True
91
98
  elif inspect.isfunction(function) or inspect.isgeneratorfunction(function):
@@ -94,33 +101,67 @@ def is_async(function):
94
101
  raise RuntimeError(f"Function {function} is a strange type {type(function)}")
95
102
 
96
103
 
104
+ def get_function_type(is_generator: Optional[bool]) -> "api_pb2.Function.FunctionType.ValueType":
105
+ return api_pb2.Function.FUNCTION_TYPE_GENERATOR if is_generator else api_pb2.Function.FUNCTION_TYPE_FUNCTION
106
+
107
+
97
108
  class FunctionInfo:
98
- """Class that helps us extract a bunch of information about a function."""
109
+ """Utility that determines serialization/deserialization mechanisms for functions
110
+
111
+ * Stored as file vs serialized
112
+ * If serialized: how to serialize the function
113
+ * If file: which module/function name should be used to retrieve
114
+
115
+ Used for populating the definition of a remote function
116
+ """
117
+
118
+ raw_f: Optional[Callable[..., Any]] # if None - this is a "class service function"
119
+ function_name: str
120
+ user_cls: Optional[type[Any]]
121
+ module_name: Optional[str]
122
+
123
+ _type: FunctionInfoType
124
+ _file: Optional[str]
125
+ _base_dir: str
126
+ _remote_dir: Optional[PurePosixPath] = None
127
+
128
+ def get_definition_type(self) -> "modal_proto.api_pb2.Function.DefinitionType.ValueType":
129
+ if self.is_serialized():
130
+ return modal_proto.api_pb2.Function.DEFINITION_TYPE_SERIALIZED
131
+ else:
132
+ return modal_proto.api_pb2.Function.DEFINITION_TYPE_FILE
133
+
134
+ def is_service_class(self):
135
+ if self.raw_f is None:
136
+ assert self.user_cls
137
+ return True
138
+ return False
99
139
 
100
140
  # TODO: we should have a bunch of unit tests for this
101
- def __init__(self, f, serialized=False, name_override: Optional[str] = None, cls: Optional[Type] = None):
141
+ def __init__(
142
+ self,
143
+ f: Optional[Callable[..., Any]],
144
+ serialized=False,
145
+ name_override: Optional[str] = None,
146
+ user_cls: Optional[type] = None,
147
+ ):
102
148
  self.raw_f = f
103
- self.cls = cls
149
+ self.user_cls = user_cls
104
150
 
105
151
  if name_override is not None:
106
152
  self.function_name = name_override
107
- elif f.__qualname__ != f.__name__ and not serialized:
108
- # Class function.
109
- if len(f.__qualname__.split(".")) > 2:
110
- raise InvalidError(
111
- f"Cannot wrap `{f.__qualname__}`:"
112
- " functions and classes used in Modal must be defined in global scope."
113
- " If trying to apply additional decorators, they may need to use `functools.wraps`."
114
- )
115
- self.function_name = f"{cls.__name__}.{f.__name__}"
153
+ elif f is None and user_cls:
154
+ # "service function" for running all methods of a class
155
+ self.function_name = f"{user_cls.__name__}.*"
156
+ elif f and user_cls:
157
+ # Method may be defined on superclass of the wrapped class
158
+ self.function_name = f"{user_cls.__name__}.{f.__name__}"
116
159
  else:
117
160
  self.function_name = f.__qualname__
118
161
 
119
- self.signature = inspect.signature(f)
120
-
121
162
  # If it's a cls, the @method could be defined in a base class in a different file.
122
- if cls is not None:
123
- module = inspect.getmodule(cls)
163
+ if user_cls is not None:
164
+ module = inspect.getmodule(user_cls)
124
165
  else:
125
166
  module = inspect.getmodule(f)
126
167
 
@@ -128,74 +169,145 @@ class FunctionInfo:
128
169
  # This is a "real" module, eg. examples.logs.f
129
170
  # Get the package path
130
171
  # Note: __import__ always returns the top-level package.
131
- self.file = os.path.abspath(module.__file__)
132
- package_paths = set([os.path.abspath(p) for p in __import__(module.__package__).__path__])
172
+ self._file = os.path.abspath(module.__file__)
173
+ package_paths = {os.path.abspath(p) for p in __import__(module.__package__).__path__}
133
174
  # There might be multiple package paths in some weird cases
134
175
  base_dirs = [
135
- base_dir for base_dir in package_paths if os.path.commonpath((base_dir, self.file)) == base_dir
176
+ base_dir for base_dir in package_paths if os.path.commonpath((base_dir, self._file)) == base_dir
136
177
  ]
137
178
 
138
179
  if not base_dirs:
139
- logger.info(f"Module files: {self.file}")
180
+ logger.info(f"Module files: {self._file}")
140
181
  logger.info(f"Package paths: {package_paths}")
141
182
  logger.info(f"Base dirs: {base_dirs}")
142
183
  raise Exception("Wasn't able to find the package directory!")
143
184
  elif len(base_dirs) > 1:
144
185
  # Base_dirs should all be prefixes of each other since they all contain `module_file`.
145
186
  base_dirs.sort(key=len)
146
- self.base_dir = base_dirs[0]
187
+ self._base_dir = base_dirs[0]
147
188
  self.module_name = module.__spec__.name
148
- self.remote_dir = ROOT_DIR / PurePosixPath(module.__package__.split(".")[0])
149
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
150
- self.type = FunctionInfoType.PACKAGE
189
+ self._remote_dir = ROOT_DIR / PurePosixPath(module.__package__.split(".")[0])
190
+ self._is_serialized = False
191
+ self._type = FunctionInfoType.PACKAGE
151
192
  elif hasattr(module, "__file__") and not serialized:
152
193
  # This generally covers the case where it's invoked with
153
194
  # python foo/bar/baz.py
154
195
 
155
196
  # If it's a cls, the @method could be defined in a base class in a different file.
156
- self.file = os.path.abspath(inspect.getfile(module))
157
- self.module_name = inspect.getmodulename(self.file)
158
- self.base_dir = os.path.dirname(self.file)
159
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
160
- self.type = FunctionInfoType.FILE
197
+ self._file = os.path.abspath(inspect.getfile(module))
198
+ self.module_name = inspect.getmodulename(self._file)
199
+ self._base_dir = os.path.dirname(self._file)
200
+ self._is_serialized = False
201
+ self._type = FunctionInfoType.FILE
161
202
  else:
162
203
  self.module_name = None
163
- self.base_dir = os.path.abspath("") # get current dir
164
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_SERIALIZED
165
- if serialized:
166
- self.type = FunctionInfoType.SERIALIZED
204
+ self._base_dir = os.path.abspath("") # get current dir
205
+ self._is_serialized = True # either explicitly, or by being in a notebook
206
+ if serialized: # if explicit
207
+ self._type = FunctionInfoType.SERIALIZED
167
208
  else:
168
- self.type = FunctionInfoType.NOTEBOOK
209
+ self._type = FunctionInfoType.NOTEBOOK
169
210
 
170
- if self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
211
+ if not self.is_serialized():
171
212
  # Sanity check that this function is defined in global scope
172
213
  # Unfortunately, there's no "clean" way to do this in Python
173
- if not is_global_function(f.__qualname__):
214
+ qualname = f.__qualname__ if f else user_cls.__qualname__
215
+ if not is_global_object(qualname):
174
216
  raise LocalFunctionError(
175
217
  "Modal can only import functions defined in global scope unless they are `serialized=True`"
176
218
  )
177
219
 
178
220
  def is_serialized(self) -> bool:
179
- return self.definition_type == api_pb2.Function.DEFINITION_TYPE_SERIALIZED
221
+ return self._is_serialized
180
222
 
181
223
  def serialized_function(self) -> bytes:
182
224
  # Note: this should only be called from .load() and not at function decoration time
183
225
  # otherwise the serialized function won't have access to variables/side effect
184
226
  # defined after it in the same file
185
227
  assert self.is_serialized()
186
- serialized_bytes = serialize(self.raw_f)
187
- logger.debug(f"Serializing {self.raw_f.__qualname__}, size is {len(serialized_bytes)}")
188
- return serialized_bytes
228
+ if self.raw_f:
229
+ serialized_bytes = serialize(self.raw_f)
230
+ logger.debug(f"Serializing {self.raw_f.__qualname__}, size is {len(serialized_bytes)}")
231
+ return serialized_bytes
232
+ else:
233
+ logger.debug(f"Serializing function for class service function {self.user_cls.__qualname__} as empty")
234
+ return b""
235
+
236
+ def get_cls_vars(self) -> dict[str, Any]:
237
+ if self.user_cls is not None:
238
+ cls_vars = {
239
+ attr: getattr(self.user_cls, attr)
240
+ for attr in dir(self.user_cls)
241
+ if not callable(getattr(self.user_cls, attr)) and not attr.startswith("__")
242
+ }
243
+ return cls_vars
244
+ return {}
245
+
246
+ def get_cls_var_attrs(self) -> dict[str, Any]:
247
+ import dis
248
+
249
+ import opcode
189
250
 
190
- def get_globals(self):
251
+ LOAD_ATTR = opcode.opmap["LOAD_ATTR"]
252
+ STORE_ATTR = opcode.opmap["STORE_ATTR"]
253
+
254
+ func = self.raw_f
255
+ code = func.__code__
256
+ f_attr_ops = set()
257
+ for instr in dis.get_instructions(code):
258
+ if instr.opcode == LOAD_ATTR:
259
+ f_attr_ops.add(instr.argval)
260
+ elif instr.opcode == STORE_ATTR:
261
+ f_attr_ops.add(instr.argval)
262
+
263
+ cls_vars = self.get_cls_vars()
264
+ f_attrs = {k: cls_vars[k] for k in cls_vars if k in f_attr_ops}
265
+ return f_attrs
266
+
267
+ def get_globals(self) -> dict[str, Any]:
191
268
  from .._vendor.cloudpickle import _extract_code_globals
192
269
 
270
+ if self.raw_f is None:
271
+ return {}
272
+
193
273
  func = self.raw_f
274
+ while hasattr(func, "__wrapped__") and func is not func.__wrapped__:
275
+ # Unwrap functions decorated using functools.wrapped (potentially multiple times)
276
+ func = func.__wrapped__
194
277
  f_globals_ref = _extract_code_globals(func.__code__)
195
278
  f_globals = {k: func.__globals__[k] for k in f_globals_ref if k in func.__globals__}
196
279
  return f_globals
197
280
 
198
- def get_entrypoint_mount(self) -> List[_Mount]:
281
+ def class_parameter_info(self) -> api_pb2.ClassParameterInfo:
282
+ if not self.user_cls:
283
+ return api_pb2.ClassParameterInfo()
284
+
285
+ # TODO(elias): Resolve circular dependencies... maybe we'll need some cls_utils module
286
+ from modal.cls import _get_class_constructor_signature, _use_annotation_parameters
287
+
288
+ if not _use_annotation_parameters(self.user_cls):
289
+ return api_pb2.ClassParameterInfo(format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PICKLE)
290
+
291
+ # annotation parameters trigger strictly typed parameterization
292
+ # which enables web endpoint for parameterized classes
293
+
294
+ modal_parameters: list[api_pb2.ClassParameterSpec] = []
295
+ signature = _get_class_constructor_signature(self.user_cls)
296
+ for param in signature.parameters.values():
297
+ has_default = param.default is not param.empty
298
+ if param.annotation not in CLASS_PARAM_TYPE_MAP:
299
+ raise InvalidError("modal.parameter() currently only support str or int types")
300
+ param_type, default_field = CLASS_PARAM_TYPE_MAP[param.annotation]
301
+ class_param_spec = api_pb2.ClassParameterSpec(name=param.name, has_default=has_default, type=param_type)
302
+ if has_default:
303
+ setattr(class_param_spec, default_field, param.default)
304
+ modal_parameters.append(class_param_spec)
305
+
306
+ return api_pb2.ClassParameterInfo(
307
+ format=api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO, schema=modal_parameters
308
+ )
309
+
310
+ def get_entrypoint_mount(self) -> list[_Mount]:
199
311
  """
200
312
  Includes:
201
313
  * Implicit mount of the function itself (the module or package that the function is part of)
@@ -207,79 +319,41 @@ class FunctionInfo:
207
319
  These are typically local modules which are imported but not part of the running package
208
320
 
209
321
  """
210
- if self.type == FunctionInfoType.NOTEBOOK:
322
+ if self._type == FunctionInfoType.NOTEBOOK:
211
323
  # Don't auto-mount anything for notebooks.
212
324
  return []
213
325
 
214
326
  # make sure the function's own entrypoint is included:
215
- if self.type == FunctionInfoType.PACKAGE:
327
+ if self._type == FunctionInfoType.PACKAGE:
216
328
  if config.get("automount"):
217
- return [_Mount.from_local_python_packages(self.module_name)]
218
- elif self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
329
+ return [_Mount._from_local_python_packages(self.module_name)]
330
+ elif not self.is_serialized():
219
331
  # mount only relevant file and __init__.py:s
220
332
  return [
221
- _Mount.from_local_dir(
222
- self.base_dir,
223
- remote_path=self.remote_dir,
333
+ _Mount._from_local_dir(
334
+ self._base_dir,
335
+ remote_path=self._remote_dir,
224
336
  recursive=True,
225
- condition=entrypoint_only_package_mount_condition(self.file),
337
+ condition=entrypoint_only_package_mount_condition(self._file),
226
338
  )
227
339
  ]
228
- elif self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
229
- remote_path = ROOT_DIR / Path(self.file).name
340
+ elif not self.is_serialized():
341
+ remote_path = ROOT_DIR / Path(self._file).name
230
342
  if not _is_modal_path(remote_path):
231
343
  return [
232
- _Mount.from_local_file(
233
- self.file,
344
+ _Mount._from_local_file(
345
+ self._file,
234
346
  remote_path=remote_path,
235
347
  )
236
348
  ]
237
349
  return []
238
350
 
239
- def get_auto_mounts(self) -> typing.List[_Mount]:
240
- # Auto-mount local modules that have been imported in global scope.
241
- # This may or may not include the "entrypoint" of the function as well, depending on how modal is invoked
242
- # Note: sys.modules may change during the iteration
243
- auto_mounts = []
244
- top_level_modules = []
245
- skip_prefixes = set()
246
- for name, module in sorted(sys.modules.items(), key=lambda kv: len(kv[0])):
247
- parent = name.rsplit(".")[0]
248
- if parent and parent in skip_prefixes:
249
- skip_prefixes.add(name)
250
- continue
251
- skip_prefixes.add(name)
252
- top_level_modules.append((name, module))
253
-
254
- for module_name, module in top_level_modules:
255
- if module_name.startswith("__"):
256
- # skip "built in" modules like __main__ and __mp_main__
257
- # the running function's main file should be included anyway
258
- continue
259
-
260
- try:
261
- # at this point we don't know if the sys.modules module should be mounted or not
262
- potential_mount = _Mount.from_local_python_packages(module_name)
263
- mount_paths = potential_mount._top_level_paths()
264
- except ModuleNotMountable:
265
- # this typically happens if the module is a built-in, has binary components or doesn't exist
266
- continue
267
-
268
- for local_path, remote_path in mount_paths:
269
- # TODO: use is_relative_to once we deprecate Python 3.8
270
- if any(str(local_path).startswith(str(p)) for p in SYS_PREFIXES) or _is_modal_path(remote_path):
271
- # skip any module that has paths in SYS_PREFIXES, or would overwrite the modal Package in the container
272
- break
273
- else:
274
- auto_mounts.append(potential_mount)
275
-
276
- return auto_mounts
277
-
278
351
  def get_tag(self):
279
352
  return self.function_name
280
353
 
281
354
  def is_nullary(self):
282
- for param in self.signature.parameters.values():
355
+ signature = inspect.signature(self.raw_f)
356
+ for param in signature.parameters.values():
283
357
  if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
284
358
  # variadic parameters are nullary
285
359
  continue
@@ -288,48 +362,263 @@ class FunctionInfo:
288
362
  return True
289
363
 
290
364
 
291
- def get_referred_objects(f: Callable) -> List[Object]:
292
- """Takes a function and returns any Modal Objects in global scope that it refers to.
365
+ def callable_has_non_self_params(f: Callable[..., Any]) -> bool:
366
+ """Return True if a callable (function, bound method, or unbound method) has parameters other than self.
293
367
 
294
- TODO: this does not yet support Object contained by another object,
295
- e.g. a list of Objects in global scope.
368
+ Used to ensure that @exit(), @asgi_app, and @wsgi_app functions don't have parameters.
296
369
  """
297
- from ..cls import Cls
298
- from ..functions import Function
299
-
300
- ret: List[Object] = []
301
- obj_queue: deque[Callable] = deque([f])
302
- objs_seen: Set[int] = set([id(f)])
303
- while obj_queue:
304
- obj = obj_queue.popleft()
305
- if isinstance(obj, (Function, Cls)):
306
- # These are always attached to stubs, so we shouldn't do anything
307
- pass
308
- elif isinstance(obj, Object):
309
- ret.append(obj)
310
- elif inspect.isfunction(obj):
311
- try:
312
- closure_vars = inspect.getclosurevars(obj)
313
- except ValueError:
314
- logger.warning(
315
- f"Could not inspect closure vars of {f} - referenced global Modal objects may or may not work in that function"
316
- )
317
- continue
370
+ return any(param.name != "self" for param in inspect.signature(f).parameters.values())
371
+
372
+
373
+ def callable_has_non_self_non_default_params(f: Callable[..., Any]) -> bool:
374
+ """Return True if a callable (function, bound method, or unbound method) has non-default parameters other than self.
375
+
376
+ Used for deprecation of default parameters in @asgi_app and @wsgi_app functions.
377
+ """
378
+ for param in inspect.signature(f).parameters.values():
379
+ if param.name == "self":
380
+ continue
381
+
382
+ if param.default != inspect.Parameter.empty:
383
+ continue
384
+
385
+ return True
386
+ return False
318
387
 
319
- for dep_obj in closure_vars.globals.values():
320
- if id(dep_obj) not in objs_seen:
321
- objs_seen.add(id(dep_obj))
322
- obj_queue.append(dep_obj)
323
- return ret
324
388
 
389
+ async def _stream_function_call_data(
390
+ client, function_call_id: str, variant: Literal["data_in", "data_out"]
391
+ ) -> AsyncGenerator[Any, None]:
392
+ """Read from the `data_in` or `data_out` stream of a function call."""
393
+ last_index = 0
325
394
 
326
- def method_has_params(f: Callable) -> bool:
327
- """Return True if a method (bound or unbound) has parameters other than self.
395
+ # TODO(gongy): generalize this logic as util for unary streams
396
+ retries_remaining = 10
397
+ delay_ms = 1
328
398
 
329
- Used for deprecation of @exit() parameters.
399
+ if variant == "data_in":
400
+ stub_fn = client.stub.FunctionCallGetDataIn
401
+ elif variant == "data_out":
402
+ stub_fn = client.stub.FunctionCallGetDataOut
403
+ else:
404
+ raise ValueError(f"Invalid variant {variant}")
405
+
406
+ while True:
407
+ req = api_pb2.FunctionCallGetDataRequest(function_call_id=function_call_id, last_index=last_index)
408
+ try:
409
+ async for chunk in stub_fn.unary_stream(req):
410
+ if chunk.index <= last_index:
411
+ continue
412
+ if chunk.data_blob_id:
413
+ message_bytes = await blob_download(chunk.data_blob_id, client.stub)
414
+ else:
415
+ message_bytes = chunk.data
416
+ message = deserialize_data_format(message_bytes, chunk.data_format, client)
417
+
418
+ last_index = chunk.index
419
+ yield message
420
+ except (GRPCError, StreamTerminatedError) as exc:
421
+ if retries_remaining > 0:
422
+ retries_remaining -= 1
423
+ if isinstance(exc, GRPCError):
424
+ if exc.status in RETRYABLE_GRPC_STATUS_CODES:
425
+ logger.debug(f"{variant} stream retrying with delay {delay_ms}ms due to {exc}")
426
+ await asyncio.sleep(delay_ms / 1000)
427
+ delay_ms = min(1000, delay_ms * 10)
428
+ continue
429
+ elif isinstance(exc, StreamTerminatedError):
430
+ continue
431
+ raise
432
+ else:
433
+ delay_ms = 1
434
+
435
+
436
+ OUTPUTS_TIMEOUT = 55.0 # seconds
437
+ ATTEMPT_TIMEOUT_GRACE_PERIOD = 5 # seconds
438
+
439
+
440
+ def exc_with_hints(exc: BaseException):
441
+ """mdmd:hidden"""
442
+ if isinstance(exc, ImportError) and exc.msg == "attempted relative import with no known parent package":
443
+ exc.msg += """\n
444
+ HINT: For relative imports to work, you might need to run your modal app as a module. Try:
445
+ - `python -m my_pkg.my_app` instead of `python my_pkg/my_app.py`
446
+ - `modal deploy my_pkg.my_app` instead of `modal deploy my_pkg/my_app.py`
447
+ """
448
+ elif isinstance(
449
+ exc, RuntimeError
450
+ ) and "CUDA error: no kernel image is available for execution on the device" in str(exc):
451
+ msg = (
452
+ exc.args[0]
453
+ + """\n
454
+ HINT: This error usually indicates an outdated CUDA version. Older versions of torch (<=1.12)
455
+ come with CUDA 10.2 by default. If pinning to an older torch version, you can specify a CUDA version
456
+ manually, for example:
457
+ - image.pip_install("torch==1.12.1+cu116", find_links="https://download.pytorch.org/whl/torch_stable.html")
458
+ """
459
+ )
460
+ exc.args = (msg,)
461
+
462
+ return exc
463
+
464
+
465
+ async def _process_result(result: api_pb2.GenericResult, data_format: int, stub, client=None):
466
+ if result.WhichOneof("data_oneof") == "data_blob_id":
467
+ data = await blob_download(result.data_blob_id, stub)
468
+ else:
469
+ data = result.data
470
+
471
+ if result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
472
+ raise FunctionTimeoutError(result.exception)
473
+ elif result.status == api_pb2.GenericResult.GENERIC_STATUS_INTERNAL_FAILURE:
474
+ raise InternalFailure(result.exception)
475
+ elif result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
476
+ if data:
477
+ try:
478
+ exc = deserialize(data, client)
479
+ except DeserializationError as deser_exc:
480
+ raise ExecutionError(
481
+ "Could not deserialize remote exception due to local error:\n"
482
+ + f"{deser_exc}\n"
483
+ + "This can happen if your local environment does not have the remote exception definitions.\n"
484
+ + "Here is the remote traceback:\n"
485
+ + f"{result.traceback}"
486
+ ) from deser_exc.__cause__
487
+ except Exception as deser_exc:
488
+ raise ExecutionError(
489
+ "Could not deserialize remote exception due to local error:\n"
490
+ + f"{deser_exc}\n"
491
+ + "Here is the remote traceback:\n"
492
+ + f"{result.traceback}"
493
+ ) from deser_exc
494
+ if not isinstance(exc, BaseException):
495
+ raise ExecutionError(f"Got remote exception of incorrect type {type(exc)}")
496
+
497
+ if result.serialized_tb:
498
+ try:
499
+ tb_dict = deserialize(result.serialized_tb, client)
500
+ line_cache = deserialize(result.tb_line_cache, client)
501
+ append_modal_tb(exc, tb_dict, line_cache)
502
+ except Exception:
503
+ pass
504
+ uc_exc = UserCodeException(exc_with_hints(exc))
505
+ raise uc_exc
506
+ raise RemoteError(result.exception)
507
+
508
+ try:
509
+ return deserialize_data_format(data, data_format, client)
510
+ except ModuleNotFoundError as deser_exc:
511
+ raise ExecutionError(
512
+ "Could not deserialize result due to error:\n"
513
+ f"{deser_exc}\n"
514
+ "This can happen if your local environment does not have a module that was used to construct the result. \n"
515
+ ) from deser_exc
516
+
517
+
518
+ async def _create_input(
519
+ args, kwargs, client, *, idx: Optional[int] = None, method_name: Optional[str] = None
520
+ ) -> api_pb2.FunctionPutInputsItem:
521
+ """Serialize function arguments and create a FunctionInput protobuf,
522
+ uploading to blob storage if needed.
330
523
  """
331
- num_params = len(inspect.signature(f).parameters)
332
- if hasattr(f, "__self__"):
333
- return num_params > 0
524
+ if idx is None:
525
+ idx = 0
526
+ if method_name is None:
527
+ method_name = "" # proto compatible
528
+
529
+ args_serialized = serialize((args, kwargs))
530
+
531
+ if len(args_serialized) > MAX_OBJECT_SIZE_BYTES:
532
+ args_blob_id = await blob_upload(args_serialized, client.stub)
533
+
534
+ return api_pb2.FunctionPutInputsItem(
535
+ input=api_pb2.FunctionInput(
536
+ args_blob_id=args_blob_id,
537
+ data_format=api_pb2.DATA_FORMAT_PICKLE,
538
+ method_name=method_name,
539
+ ),
540
+ idx=idx,
541
+ )
542
+ else:
543
+ return api_pb2.FunctionPutInputsItem(
544
+ input=api_pb2.FunctionInput(
545
+ args=args_serialized,
546
+ data_format=api_pb2.DATA_FORMAT_PICKLE,
547
+ method_name=method_name,
548
+ ),
549
+ idx=idx,
550
+ )
551
+
552
+
553
+ def _get_suffix_from_web_url_info(url_info: api_pb2.WebUrlInfo) -> str:
554
+ if url_info.truncated:
555
+ suffix = " [grey70](label truncated)[/grey70]"
556
+ elif url_info.label_stolen:
557
+ suffix = " [grey70](label stolen)[/grey70]"
334
558
  else:
335
- return num_params > 1
559
+ suffix = ""
560
+ return suffix
561
+
562
+
563
+ class FunctionCreationStatus:
564
+ # TODO(michael) this really belongs with other output-related code
565
+ # but moving it here so we can use it when loading a function with output disabled
566
+ tag: str
567
+ response: Optional[api_pb2.FunctionCreateResponse] = None
568
+
569
+ def __init__(self, resolver, tag):
570
+ self.resolver = resolver
571
+ self.tag = tag
572
+
573
+ def __enter__(self):
574
+ self.status_row = self.resolver.add_status_row()
575
+ self.status_row.message(f"Creating function {self.tag}...")
576
+ return self
577
+
578
+ def set_response(self, resp: api_pb2.FunctionCreateResponse):
579
+ self.response = resp
580
+
581
+ def __exit__(self, exc_type, exc_val, exc_tb):
582
+ if exc_type:
583
+ raise exc_val
584
+
585
+ if not self.response:
586
+ self.status_row.finish(f"Unknown error when creating function {self.tag}")
587
+
588
+ elif self.response.function.web_url:
589
+ url_info = self.response.function.web_url_info
590
+ requires_proxy_auth = self.response.function.webhook_config.requires_proxy_auth
591
+ proxy_auth_suffix = " 🔑" if requires_proxy_auth else ""
592
+ # Ensure terms used here match terms used in modal.com/docs/guide/webhook-urls doc.
593
+ suffix = _get_suffix_from_web_url_info(url_info)
594
+ # TODO: this is only printed when we're showing progress. Maybe move this somewhere else.
595
+ web_url = self.response.handle_metadata.web_url
596
+ self.status_row.finish(
597
+ f"Created web function {self.tag} => [magenta underline]{web_url}[/magenta underline]"
598
+ f"{proxy_auth_suffix}{suffix}"
599
+ )
600
+
601
+ # Print custom domain in terminal
602
+ for custom_domain in self.response.function.custom_domain_info:
603
+ custom_domain_status_row = self.resolver.add_status_row()
604
+ custom_domain_status_row.finish(
605
+ f"Custom domain for {self.tag} => [magenta underline]" f"{custom_domain.url}[/magenta underline]"
606
+ )
607
+ else:
608
+ self.status_row.finish(f"Created function {self.tag}.")
609
+ if self.response.function.method_definitions_set:
610
+ for method_definition in self.response.function.method_definitions.values():
611
+ if method_definition.web_url:
612
+ url_info = method_definition.web_url_info
613
+ suffix = _get_suffix_from_web_url_info(url_info)
614
+ class_web_endpoint_method_status_row = self.resolver.add_status_row()
615
+ class_web_endpoint_method_status_row.finish(
616
+ f"Created web endpoint for {method_definition.function_name} => [magenta underline]"
617
+ f"{method_definition.web_url}[/magenta underline]{suffix}"
618
+ )
619
+ for custom_domain in method_definition.custom_domain_info:
620
+ custom_domain_status_row = self.resolver.add_status_row()
621
+ custom_domain_status_row.finish(
622
+ f"Custom domain for {method_definition.function_name} => [magenta underline]"
623
+ f"{custom_domain.url}[/magenta underline]"
624
+ )