modal 0.62.115__py3-none-any.whl → 0.72.13__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 +402 -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 +1025 -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 +196 -0
  84. modal/functions.py +846 -428
  85. modal/functions.pyi +446 -387
  86. modal/gpu.py +57 -44
  87. modal/image.py +943 -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.13.dist-info}/METADATA +5 -5
  128. modal-0.72.13.dist-info/RECORD +174 -0
  129. {modal-0.62.115.dist-info → modal-0.72.13.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.13.dist-info}/LICENSE +0 -0
  219. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
  220. {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
@@ -2,25 +2,32 @@
2
2
  import asyncio
3
3
  import inspect
4
4
  import os
5
- from collections import deque
5
+ from collections.abc import AsyncGenerator
6
6
  from enum import Enum
7
7
  from pathlib import Path, PurePosixPath
8
- from typing import Any, AsyncIterator, Callable, List, Literal, Optional, Set, Type
8
+ from typing import Any, Callable, Literal, Optional
9
9
 
10
10
  from grpclib import GRPCError
11
11
  from grpclib.exceptions import StreamTerminatedError
12
12
  from synchronicity.exceptions import UserCodeException
13
13
 
14
+ import modal_proto
14
15
  from modal_proto import api_pb2
15
16
 
16
17
  from .._serialization import deserialize, deserialize_data_format, serialize
17
18
  from .._traceback import append_modal_tb
18
19
  from ..config import config, logger
19
- from ..exception import ExecutionError, FunctionTimeoutError, InvalidError, RemoteError
20
+ from ..exception import (
21
+ DeserializationError,
22
+ ExecutionError,
23
+ FunctionTimeoutError,
24
+ InternalFailure,
25
+ InvalidError,
26
+ RemoteError,
27
+ )
20
28
  from ..mount import ROOT_DIR, _is_modal_path, _Mount
21
- from ..object import Object
22
29
  from .blob_utils import MAX_OBJECT_SIZE_BYTES, blob_download, blob_upload
23
- from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES, unary_stream
30
+ from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
24
31
 
25
32
 
26
33
  class FunctionInfoType(Enum):
@@ -30,6 +37,13 @@ class FunctionInfoType(Enum):
30
37
  NOTEBOOK = "notebook"
31
38
 
32
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
+
33
47
  class LocalFunctionError(InvalidError):
34
48
  """Raised if a function declared in a non-global scope is used in an impermissible way"""
35
49
 
@@ -49,8 +63,25 @@ def entrypoint_only_package_mount_condition(entrypoint_file):
49
63
  return inner
50
64
 
51
65
 
52
- def is_global_function(function_qual_name):
53
- return "<locals>" not in function_qual_name.split(".")
66
+ def is_global_object(object_qual_name: str):
67
+ return "<locals>" not in object_qual_name.split(".")
68
+
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
+
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__
54
85
 
55
86
 
56
87
  def is_async(function):
@@ -60,6 +91,8 @@ def is_async(function):
60
91
  # coerce the type. For now let's make a determination based on inspecting the function definition.
61
92
  # This sometimes isn't correct, since a "vanilla" Python function can return a coroutine if it
62
93
  # wraps async code or similar. Let's revisit this shortly.
94
+ if inspect.ismethod(function):
95
+ function = function.__func__ # inspect the underlying function
63
96
  if inspect.iscoroutinefunction(function) or inspect.isasyncgenfunction(function):
64
97
  return True
65
98
  elif inspect.isfunction(function) or inspect.isgeneratorfunction(function):
@@ -68,51 +101,67 @@ def is_async(function):
68
101
  raise RuntimeError(f"Function {function} is a strange type {type(function)}")
69
102
 
70
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
+
71
108
  class FunctionInfo:
72
- """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
73
114
 
74
- raw_f: Callable[..., Any]
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"
75
119
  function_name: str
76
- cls: Optional[Type[Any]]
77
- definition_type: "api_pb2.Function.DefinitionType.ValueType"
120
+ user_cls: Optional[type[Any]]
78
121
  module_name: Optional[str]
79
122
 
80
123
  _type: FunctionInfoType
81
- _signature: Optional[inspect.Signature]
82
124
  _file: Optional[str]
83
125
  _base_dir: str
84
126
  _remote_dir: Optional[PurePosixPath] = None
85
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
139
+
86
140
  # TODO: we should have a bunch of unit tests for this
87
141
  def __init__(
88
142
  self,
89
- f: Callable[..., Any],
143
+ f: Optional[Callable[..., Any]],
90
144
  serialized=False,
91
145
  name_override: Optional[str] = None,
92
- cls: Optional[Type] = None,
146
+ user_cls: Optional[type] = None,
93
147
  ):
94
148
  self.raw_f = f
95
- self.cls = cls
149
+ self.user_cls = user_cls
96
150
 
97
151
  if name_override is not None:
98
152
  self.function_name = name_override
99
- elif f.__qualname__ != f.__name__ and not serialized:
100
- # Class function.
101
- if len(f.__qualname__.split(".")) > 2:
102
- raise InvalidError(
103
- f"Cannot wrap `{f.__qualname__}`:"
104
- " functions and classes used in Modal must be defined in global scope."
105
- " If trying to apply additional decorators, they may need to use `functools.wraps`."
106
- )
107
- 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__}"
108
159
  else:
109
160
  self.function_name = f.__qualname__
110
161
 
111
- self._signature = inspect.signature(f)
112
-
113
162
  # If it's a cls, the @method could be defined in a base class in a different file.
114
- if cls is not None:
115
- module = inspect.getmodule(cls)
163
+ if user_cls is not None:
164
+ module = inspect.getmodule(user_cls)
116
165
  else:
117
166
  module = inspect.getmodule(f)
118
167
 
@@ -121,7 +170,7 @@ class FunctionInfo:
121
170
  # Get the package path
122
171
  # Note: __import__ always returns the top-level package.
123
172
  self._file = os.path.abspath(module.__file__)
124
- package_paths = set([os.path.abspath(p) for p in __import__(module.__package__).__path__])
173
+ package_paths = {os.path.abspath(p) for p in __import__(module.__package__).__path__}
125
174
  # There might be multiple package paths in some weird cases
126
175
  base_dirs = [
127
176
  base_dir for base_dir in package_paths if os.path.commonpath((base_dir, self._file)) == base_dir
@@ -138,7 +187,7 @@ class FunctionInfo:
138
187
  self._base_dir = base_dirs[0]
139
188
  self.module_name = module.__spec__.name
140
189
  self._remote_dir = ROOT_DIR / PurePosixPath(module.__package__.split(".")[0])
141
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
190
+ self._is_serialized = False
142
191
  self._type = FunctionInfoType.PACKAGE
143
192
  elif hasattr(module, "__file__") and not serialized:
144
193
  # This generally covers the case where it's invoked with
@@ -148,46 +197,117 @@ class FunctionInfo:
148
197
  self._file = os.path.abspath(inspect.getfile(module))
149
198
  self.module_name = inspect.getmodulename(self._file)
150
199
  self._base_dir = os.path.dirname(self._file)
151
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_FILE
200
+ self._is_serialized = False
152
201
  self._type = FunctionInfoType.FILE
153
202
  else:
154
203
  self.module_name = None
155
204
  self._base_dir = os.path.abspath("") # get current dir
156
- self.definition_type = api_pb2.Function.DEFINITION_TYPE_SERIALIZED
157
- if serialized:
205
+ self._is_serialized = True # either explicitly, or by being in a notebook
206
+ if serialized: # if explicit
158
207
  self._type = FunctionInfoType.SERIALIZED
159
208
  else:
160
209
  self._type = FunctionInfoType.NOTEBOOK
161
210
 
162
- if self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
211
+ if not self.is_serialized():
163
212
  # Sanity check that this function is defined in global scope
164
213
  # Unfortunately, there's no "clean" way to do this in Python
165
- if not is_global_function(f.__qualname__):
214
+ qualname = f.__qualname__ if f else user_cls.__qualname__
215
+ if not is_global_object(qualname):
166
216
  raise LocalFunctionError(
167
217
  "Modal can only import functions defined in global scope unless they are `serialized=True`"
168
218
  )
169
219
 
170
220
  def is_serialized(self) -> bool:
171
- return self.definition_type == api_pb2.Function.DEFINITION_TYPE_SERIALIZED
221
+ return self._is_serialized
172
222
 
173
223
  def serialized_function(self) -> bytes:
174
224
  # Note: this should only be called from .load() and not at function decoration time
175
225
  # otherwise the serialized function won't have access to variables/side effect
176
226
  # defined after it in the same file
177
227
  assert self.is_serialized()
178
- serialized_bytes = serialize(self.raw_f)
179
- logger.debug(f"Serializing {self.raw_f.__qualname__}, size is {len(serialized_bytes)}")
180
- 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""
181
235
 
182
- def get_globals(self):
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
250
+
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]:
183
268
  from .._vendor.cloudpickle import _extract_code_globals
184
269
 
270
+ if self.raw_f is None:
271
+ return {}
272
+
185
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__
186
277
  f_globals_ref = _extract_code_globals(func.__code__)
187
278
  f_globals = {k: func.__globals__[k] for k in f_globals_ref if k in func.__globals__}
188
279
  return f_globals
189
280
 
190
- 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]:
191
311
  """
192
312
  Includes:
193
313
  * Implicit mount of the function itself (the module or package that the function is part of)
@@ -206,22 +326,22 @@ class FunctionInfo:
206
326
  # make sure the function's own entrypoint is included:
207
327
  if self._type == FunctionInfoType.PACKAGE:
208
328
  if config.get("automount"):
209
- return [_Mount.from_local_python_packages(self.module_name)]
210
- 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():
211
331
  # mount only relevant file and __init__.py:s
212
332
  return [
213
- _Mount.from_local_dir(
333
+ _Mount._from_local_dir(
214
334
  self._base_dir,
215
335
  remote_path=self._remote_dir,
216
336
  recursive=True,
217
337
  condition=entrypoint_only_package_mount_condition(self._file),
218
338
  )
219
339
  ]
220
- elif self.definition_type == api_pb2.Function.DEFINITION_TYPE_FILE:
340
+ elif not self.is_serialized():
221
341
  remote_path = ROOT_DIR / Path(self._file).name
222
342
  if not _is_modal_path(remote_path):
223
343
  return [
224
- _Mount.from_local_file(
344
+ _Mount._from_local_file(
225
345
  self._file,
226
346
  remote_path=remote_path,
227
347
  )
@@ -232,7 +352,8 @@ class FunctionInfo:
232
352
  return self.function_name
233
353
 
234
354
  def is_nullary(self):
235
- for param in self._signature.parameters.values():
355
+ signature = inspect.signature(self.raw_f)
356
+ for param in signature.parameters.values():
236
357
  if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
237
358
  # variadic parameters are nullary
238
359
  continue
@@ -241,59 +362,39 @@ class FunctionInfo:
241
362
  return True
242
363
 
243
364
 
244
- def get_referred_objects(f: Callable) -> List[Object]:
245
- """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.
246
367
 
247
- TODO: this does not yet support Object contained by another object,
248
- 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.
249
369
  """
250
- from ..cls import Cls
251
- from ..functions import Function
252
-
253
- ret: List[Object] = []
254
- obj_queue: deque[Callable] = deque([f])
255
- objs_seen: Set[int] = set([id(f)])
256
- while obj_queue:
257
- obj = obj_queue.popleft()
258
- if isinstance(obj, (Function, Cls)):
259
- # These are always attached to stubs, so we shouldn't do anything
260
- pass
261
- elif isinstance(obj, Object):
262
- ret.append(obj)
263
- elif inspect.isfunction(obj):
264
- try:
265
- closure_vars = inspect.getclosurevars(obj)
266
- except ValueError:
267
- logger.warning(
268
- f"Could not inspect closure vars of {f} - referenced global Modal objects may or may not work in that function"
269
- )
270
- continue
370
+ return any(param.name != "self" for param in inspect.signature(f).parameters.values())
271
371
 
272
- for dep_obj in closure_vars.globals.values():
273
- if id(dep_obj) not in objs_seen:
274
- objs_seen.add(id(dep_obj))
275
- obj_queue.append(dep_obj)
276
- return ret
277
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.
278
375
 
279
- def method_has_params(f: Callable) -> bool:
280
- """Return True if a method (bound or unbound) has parameters other than self.
281
-
282
- Used for deprecation of @exit() parameters.
376
+ Used for deprecation of default parameters in @asgi_app and @wsgi_app functions.
283
377
  """
284
- num_params = len(inspect.signature(f).parameters)
285
- if hasattr(f, "__self__"):
286
- return num_params > 0
287
- else:
288
- return num_params > 1
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
289
387
 
290
388
 
291
389
  async def _stream_function_call_data(
292
390
  client, function_call_id: str, variant: Literal["data_in", "data_out"]
293
- ) -> AsyncIterator[Any]:
391
+ ) -> AsyncGenerator[Any, None]:
294
392
  """Read from the `data_in` or `data_out` stream of a function call."""
295
393
  last_index = 0
394
+
395
+ # TODO(gongy): generalize this logic as util for unary streams
296
396
  retries_remaining = 10
397
+ delay_ms = 1
297
398
 
298
399
  if variant == "data_in":
299
400
  stub_fn = client.stub.FunctionCallGetDataIn
@@ -305,26 +406,31 @@ async def _stream_function_call_data(
305
406
  while True:
306
407
  req = api_pb2.FunctionCallGetDataRequest(function_call_id=function_call_id, last_index=last_index)
307
408
  try:
308
- async for chunk in unary_stream(stub_fn, req):
409
+ async for chunk in stub_fn.unary_stream(req):
309
410
  if chunk.index <= last_index:
310
411
  continue
311
- last_index = chunk.index
312
412
  if chunk.data_blob_id:
313
413
  message_bytes = await blob_download(chunk.data_blob_id, client.stub)
314
414
  else:
315
415
  message_bytes = chunk.data
316
416
  message = deserialize_data_format(message_bytes, chunk.data_format, client)
417
+
418
+ last_index = chunk.index
317
419
  yield message
318
420
  except (GRPCError, StreamTerminatedError) as exc:
319
421
  if retries_remaining > 0:
320
422
  retries_remaining -= 1
321
423
  if isinstance(exc, GRPCError):
322
424
  if exc.status in RETRYABLE_GRPC_STATUS_CODES:
323
- await asyncio.sleep(1.0)
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)
324
428
  continue
325
429
  elif isinstance(exc, StreamTerminatedError):
326
430
  continue
327
431
  raise
432
+ else:
433
+ delay_ms = 1
328
434
 
329
435
 
330
436
  OUTPUTS_TIMEOUT = 55.0 # seconds
@@ -364,18 +470,27 @@ async def _process_result(result: api_pb2.GenericResult, data_format: int, stub,
364
470
 
365
471
  if result.status == api_pb2.GenericResult.GENERIC_STATUS_TIMEOUT:
366
472
  raise FunctionTimeoutError(result.exception)
473
+ elif result.status == api_pb2.GenericResult.GENERIC_STATUS_INTERNAL_FAILURE:
474
+ raise InternalFailure(result.exception)
367
475
  elif result.status != api_pb2.GenericResult.GENERIC_STATUS_SUCCESS:
368
476
  if data:
369
477
  try:
370
478
  exc = deserialize(data, client)
371
- except Exception as deser_exc:
479
+ except DeserializationError as deser_exc:
372
480
  raise ExecutionError(
373
481
  "Could not deserialize remote exception due to local error:\n"
374
482
  + f"{deser_exc}\n"
375
483
  + "This can happen if your local environment does not have the remote exception definitions.\n"
376
484
  + "Here is the remote traceback:\n"
377
485
  + f"{result.traceback}"
378
- )
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
379
494
  if not isinstance(exc, BaseException):
380
495
  raise ExecutionError(f"Got remote exception of incorrect type {type(exc)}")
381
496
 
@@ -395,17 +510,21 @@ async def _process_result(result: api_pb2.GenericResult, data_format: int, stub,
395
510
  except ModuleNotFoundError as deser_exc:
396
511
  raise ExecutionError(
397
512
  "Could not deserialize result due to error:\n"
398
- + f"{deser_exc}\n"
399
- + "This can happen if your local environment does not have a module that was used to construct the result. \n"
400
- )
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
401
516
 
402
517
 
403
- async def _create_input(args, kwargs, client, idx: Optional[int] = None) -> api_pb2.FunctionPutInputsItem:
518
+ async def _create_input(
519
+ args, kwargs, client, *, idx: Optional[int] = None, method_name: Optional[str] = None
520
+ ) -> api_pb2.FunctionPutInputsItem:
404
521
  """Serialize function arguments and create a FunctionInput protobuf,
405
522
  uploading to blob storage if needed.
406
523
  """
407
524
  if idx is None:
408
525
  idx = 0
526
+ if method_name is None:
527
+ method_name = "" # proto compatible
409
528
 
410
529
  args_serialized = serialize((args, kwargs))
411
530
 
@@ -413,11 +532,93 @@ async def _create_input(args, kwargs, client, idx: Optional[int] = None) -> api_
413
532
  args_blob_id = await blob_upload(args_serialized, client.stub)
414
533
 
415
534
  return api_pb2.FunctionPutInputsItem(
416
- input=api_pb2.FunctionInput(args_blob_id=args_blob_id, data_format=api_pb2.DATA_FORMAT_PICKLE),
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
+ ),
417
540
  idx=idx,
418
541
  )
419
542
  else:
420
543
  return api_pb2.FunctionPutInputsItem(
421
- input=api_pb2.FunctionInput(args=args_serialized, data_format=api_pb2.DATA_FORMAT_PICKLE),
544
+ input=api_pb2.FunctionInput(
545
+ args=args_serialized,
546
+ data_format=api_pb2.DATA_FORMAT_PICKLE,
547
+ method_name=method_name,
548
+ ),
422
549
  idx=idx,
423
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]"
558
+ else:
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
+ )