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.
- modal/__init__.py +13 -9
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +402 -398
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -60
- modal/_resources.py +26 -7
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1025 -0
- modal/{execution_context.py → _runtime/execution_context.py} +11 -2
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +123 -6
- modal/_traceback.py +47 -187
- modal/_tunnel.py +50 -14
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +386 -104
- modal/_utils/blob_utils.py +157 -186
- modal/_utils/bytes_io_segment_payload.py +97 -0
- modal/_utils/deprecation.py +89 -0
- modal/_utils/docker_utils.py +98 -0
- modal/_utils/function_utils.py +299 -98
- modal/_utils/grpc_testing.py +47 -34
- modal/_utils/grpc_utils.py +54 -21
- modal/_utils/hash_utils.py +51 -10
- modal/_utils/http_utils.py +39 -9
- modal/_utils/logger.py +2 -1
- modal/_utils/mount_utils.py +34 -16
- modal/_utils/name_utils.py +58 -0
- modal/_utils/package_utils.py +14 -1
- modal/_utils/pattern_utils.py +205 -0
- modal/_utils/rand_pb_testing.py +3 -3
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +12 -10
- modal/app.py +561 -323
- modal/app.pyi +474 -262
- modal/call_graph.py +7 -6
- modal/cli/_download.py +22 -6
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +203 -42
- modal/cli/config.py +12 -5
- modal/cli/container.py +61 -13
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +21 -48
- modal/cli/launch.py +28 -14
- modal/cli/network_file_system.py +57 -21
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +34 -9
- modal/cli/programs/vscode.py +58 -8
- modal/cli/queues.py +131 -0
- modal/cli/run.py +199 -96
- modal/cli/secret.py +5 -4
- modal/cli/token.py +7 -2
- modal/cli/utils.py +74 -8
- modal/cli/volume.py +97 -56
- modal/client.py +248 -144
- modal/client.pyi +156 -124
- modal/cloud_bucket_mount.py +43 -30
- modal/cloud_bucket_mount.pyi +32 -25
- modal/cls.py +528 -141
- modal/cls.pyi +189 -145
- modal/config.py +32 -15
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +50 -54
- modal/dict.pyi +120 -164
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +30 -43
- modal/experimental.py +62 -2
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +196 -0
- modal/functions.py +846 -428
- modal/functions.pyi +446 -387
- modal/gpu.py +57 -44
- modal/image.py +943 -417
- modal/image.pyi +584 -245
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +223 -90
- modal/mount.pyi +241 -243
- modal/network_file_system.py +85 -86
- modal/network_file_system.pyi +151 -110
- modal/object.py +66 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +73 -47
- modal/parallel_map.pyi +51 -63
- modal/partial_function.py +272 -107
- modal/partial_function.pyi +219 -120
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +96 -72
- modal/queue.pyi +210 -135
- modal/requirements/2024.04.txt +2 -1
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +45 -4
- modal/runner.py +325 -203
- modal/runner.pyi +124 -110
- modal/running_app.py +27 -4
- modal/sandbox.py +509 -231
- modal/sandbox.pyi +396 -169
- modal/schedule.py +2 -2
- modal/scheduler_placement.py +20 -3
- modal/secret.py +41 -25
- modal/secret.pyi +62 -42
- modal/serving.py +39 -49
- modal/serving.pyi +37 -43
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +123 -137
- modal/volume.pyi +228 -221
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/METADATA +5 -5
- modal-0.72.13.dist-info/RECORD +174 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/top_level.txt +0 -1
- modal_docs/gen_reference_docs.py +3 -1
- modal_docs/mdmd/mdmd.py +0 -1
- modal_docs/mdmd/signatures.py +1 -2
- modal_global_objects/images/base_images.py +28 -0
- modal_global_objects/mounts/python_standalone.py +2 -2
- modal_proto/__init__.py +1 -1
- modal_proto/api.proto +1231 -531
- modal_proto/api_grpc.py +750 -430
- modal_proto/api_pb2.py +2102 -1176
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1329 -675
- modal_proto/api_pb2_grpc.pyi +1416 -0
- modal_proto/modal_api_grpc.py +149 -0
- modal_proto/modal_options_grpc.py +3 -0
- modal_proto/options_pb2.pyi +20 -0
- modal_proto/options_pb2_grpc.pyi +7 -0
- modal_proto/py.typed +0 -0
- modal_version/__init__.py +1 -1
- modal_version/_version_generated.py +2 -2
- modal/_asgi.py +0 -370
- modal/_container_exec.py +0 -128
- modal/_container_io_manager.py +0 -646
- modal/_container_io_manager.pyi +0 -412
- modal/_sandbox_shell.py +0 -49
- modal/app_utils.py +0 -20
- modal/app_utils.pyi +0 -17
- modal/execution_context.pyi +0 -37
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal-0.62.115.dist-info/RECORD +0 -207
- modal_global_objects/images/conda.py +0 -15
- modal_global_objects/images/debian_slim.py +0 -15
- modal_global_objects/images/micromamba.py +0 -15
- test/__init__.py +0 -1
- test/aio_test.py +0 -12
- test/async_utils_test.py +0 -279
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -674
- test/client_test.py +0 -203
- test/cloud_bucket_mount_test.py +0 -22
- test/cls_test.py +0 -636
- test/config_test.py +0 -149
- test/conftest.py +0 -1485
- test/container_app_test.py +0 -50
- test/container_test.py +0 -1405
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -51
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -791
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -82
- test/helpers.py +0 -47
- test/image_test.py +0 -814
- test/live_reload_test.py +0 -80
- test/lookup_test.py +0 -70
- test/mdmd_test.py +0 -329
- test/mount_test.py +0 -162
- test/mounted_files_test.py +0 -327
- test/network_file_system_test.py +0 -188
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -115
- test/resolver_test.py +0 -59
- test/retries_test.py +0 -67
- test/runner_test.py +0 -85
- test/sandbox_test.py +0 -191
- test/schedule_test.py +0 -15
- test/scheduler_placement_test.py +0 -57
- test/secret_test.py +0 -89
- test/serialization_test.py +0 -50
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -361
- test/test_asgi_wrapper.py +0 -234
- test/token_flow_test.py +0 -18
- test/traceback_test.py +0 -135
- test/tunnel_test.py +0 -29
- test/utils_test.py +0 -88
- test/version_test.py +0 -14
- test/volume_test.py +0 -397
- test/watcher_test.py +0 -58
- test/webhook_test.py +0 -145
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/LICENSE +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/WHEEL +0 -0
- {modal-0.62.115.dist-info → modal-0.72.13.dist-info}/entry_points.txt +0 -0
modal/_utils/function_utils.py
CHANGED
@@ -2,25 +2,32 @@
|
|
2
2
|
import asyncio
|
3
3
|
import inspect
|
4
4
|
import os
|
5
|
-
from collections import
|
5
|
+
from collections.abc import AsyncGenerator
|
6
6
|
from enum import Enum
|
7
7
|
from pathlib import Path, PurePosixPath
|
8
|
-
from typing import Any,
|
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
|
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
|
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
|
53
|
-
return "<locals>" not in
|
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
|
-
"""
|
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
|
-
|
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
|
-
|
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
|
-
|
146
|
+
user_cls: Optional[type] = None,
|
93
147
|
):
|
94
148
|
self.raw_f = f
|
95
|
-
self.
|
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
|
100
|
-
#
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
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
|
115
|
-
module = inspect.getmodule(
|
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 =
|
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.
|
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.
|
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.
|
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.
|
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
|
-
|
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.
|
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
|
-
|
179
|
-
|
180
|
-
|
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
|
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
|
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.
|
210
|
-
elif self.
|
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.
|
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.
|
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.
|
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
|
-
|
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
|
245
|
-
"""
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
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
|
-
) ->
|
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(
|
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
|
-
|
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
|
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
|
-
|
399
|
-
|
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(
|
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(
|
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(
|
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
|
+
)
|