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.
- modal/__init__.py +17 -13
- modal/__main__.py +41 -3
- modal/_clustered_functions.py +80 -0
- modal/_clustered_functions.pyi +22 -0
- modal/_container_entrypoint.py +420 -937
- modal/_ipython.py +3 -13
- modal/_location.py +17 -10
- modal/_output.py +243 -99
- modal/_pty.py +2 -2
- modal/_resolver.py +55 -59
- modal/_resources.py +51 -0
- modal/_runtime/__init__.py +1 -0
- modal/_runtime/asgi.py +519 -0
- modal/_runtime/container_io_manager.py +1036 -0
- modal/_runtime/execution_context.py +89 -0
- modal/_runtime/telemetry.py +169 -0
- modal/_runtime/user_code_imports.py +356 -0
- modal/_serialization.py +134 -9
- modal/_traceback.py +47 -187
- modal/_tunnel.py +52 -16
- modal/_tunnel.pyi +19 -36
- modal/_utils/app_utils.py +3 -17
- modal/_utils/async_utils.py +479 -100
- 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 +460 -171
- modal/_utils/grpc_testing.py +47 -31
- modal/_utils/grpc_utils.py +62 -109
- modal/_utils/hash_utils.py +61 -19
- 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 +5 -7
- modal/_utils/shell_utils.py +15 -49
- modal/_vendor/a2wsgi_wsgi.py +62 -72
- modal/_vendor/cloudpickle.py +1 -1
- modal/_watcher.py +14 -12
- modal/app.py +1003 -314
- modal/app.pyi +540 -264
- modal/call_graph.py +7 -6
- modal/cli/_download.py +63 -53
- modal/cli/_traceback.py +200 -0
- modal/cli/app.py +205 -45
- modal/cli/config.py +12 -5
- modal/cli/container.py +62 -14
- modal/cli/dict.py +128 -0
- modal/cli/entry_point.py +26 -13
- modal/cli/environment.py +40 -9
- modal/cli/import_refs.py +64 -58
- modal/cli/launch.py +32 -18
- modal/cli/network_file_system.py +64 -83
- modal/cli/profile.py +1 -1
- modal/cli/programs/run_jupyter.py +35 -10
- modal/cli/programs/vscode.py +60 -10
- modal/cli/queues.py +131 -0
- modal/cli/run.py +234 -131
- modal/cli/secret.py +8 -7
- modal/cli/token.py +7 -2
- modal/cli/utils.py +79 -10
- modal/cli/volume.py +110 -109
- modal/client.py +250 -144
- modal/client.pyi +157 -118
- modal/cloud_bucket_mount.py +108 -34
- modal/cloud_bucket_mount.pyi +32 -38
- modal/cls.py +535 -148
- modal/cls.pyi +190 -146
- modal/config.py +41 -19
- modal/container_process.py +177 -0
- modal/container_process.pyi +82 -0
- modal/dict.py +111 -65
- modal/dict.pyi +136 -131
- modal/environments.py +106 -5
- modal/environments.pyi +77 -25
- modal/exception.py +34 -43
- modal/experimental.py +61 -2
- modal/extensions/ipython.py +5 -5
- modal/file_io.py +537 -0
- modal/file_io.pyi +235 -0
- modal/file_pattern_matcher.py +197 -0
- modal/functions.py +906 -911
- modal/functions.pyi +466 -430
- modal/gpu.py +57 -44
- modal/image.py +1089 -479
- modal/image.pyi +584 -228
- modal/io_streams.py +434 -0
- modal/io_streams.pyi +122 -0
- modal/mount.py +314 -101
- modal/mount.pyi +241 -235
- modal/network_file_system.py +92 -92
- modal/network_file_system.pyi +152 -110
- modal/object.py +67 -36
- modal/object.pyi +166 -143
- modal/output.py +63 -0
- modal/parallel_map.py +434 -0
- modal/parallel_map.pyi +75 -0
- modal/partial_function.py +282 -117
- modal/partial_function.pyi +222 -129
- modal/proxy.py +15 -12
- modal/proxy.pyi +3 -8
- modal/queue.py +182 -65
- modal/queue.pyi +218 -118
- modal/requirements/2024.04.txt +29 -0
- modal/requirements/2024.10.txt +16 -0
- modal/requirements/README.md +21 -0
- modal/requirements/base-images.json +22 -0
- modal/retries.py +48 -7
- modal/runner.py +459 -156
- modal/runner.pyi +135 -71
- modal/running_app.py +38 -0
- modal/sandbox.py +514 -236
- modal/sandbox.pyi +397 -169
- modal/schedule.py +4 -4
- modal/scheduler_placement.py +20 -3
- modal/secret.py +56 -31
- modal/secret.pyi +62 -42
- modal/serving.py +51 -56
- modal/serving.pyi +44 -36
- modal/stream_type.py +15 -0
- modal/token_flow.py +5 -3
- modal/token_flow.pyi +37 -32
- modal/volume.py +285 -157
- modal/volume.pyi +249 -184
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/METADATA +7 -7
- modal-0.72.11.dist-info/RECORD +174 -0
- {modal-0.62.16.dist-info → modal-0.72.11.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 +5 -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 +1288 -533
- modal_proto/api_grpc.py +856 -456
- modal_proto/api_pb2.py +2165 -1157
- modal_proto/api_pb2.pyi +8859 -0
- modal_proto/api_pb2_grpc.py +1674 -855
- 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_entrypoint.pyi +0 -378
- modal/_container_exec.py +0 -128
- modal/_sandbox_shell.py +0 -49
- modal/shared_volume.py +0 -23
- modal/shared_volume.pyi +0 -24
- modal/stub.py +0 -783
- modal/stub.pyi +0 -332
- modal-0.62.16.dist-info/RECORD +0 -198
- 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 -262
- test/blob_test.py +0 -67
- test/cli_imports_test.py +0 -149
- test/cli_test.py +0 -659
- test/client_test.py +0 -194
- test/cls_test.py +0 -630
- test/config_test.py +0 -137
- test/conftest.py +0 -1420
- test/container_app_test.py +0 -32
- test/container_test.py +0 -1389
- test/cpu_test.py +0 -23
- test/decorator_test.py +0 -85
- test/deprecation_test.py +0 -34
- test/dict_test.py +0 -33
- test/e2e_test.py +0 -68
- test/error_test.py +0 -7
- test/function_serialization_test.py +0 -32
- test/function_test.py +0 -653
- test/function_utils_test.py +0 -101
- test/gpu_test.py +0 -159
- test/grpc_utils_test.py +0 -141
- test/helpers.py +0 -42
- test/image_test.py +0 -669
- 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 -329
- test/network_file_system_test.py +0 -181
- test/notebook_test.py +0 -66
- test/object_test.py +0 -41
- test/package_utils_test.py +0 -25
- test/queue_test.py +0 -97
- test/resolver_test.py +0 -58
- 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 -29
- test/secret_test.py +0 -78
- test/serialization_test.py +0 -42
- test/stub_composition_test.py +0 -10
- test/stub_test.py +0 -360
- 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 -341
- test/watcher_test.py +0 -30
- test/webhook_test.py +0 -146
- /modal/{requirements.312.txt → requirements/2023.12.312.txt} +0 -0
- /modal/{requirements.txt → requirements/2023.12.txt} +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/LICENSE +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/WHEEL +0 -0
- {modal-0.62.16.dist-info → modal-0.72.11.dist-info}/entry_points.txt +0 -0
modal/_utils/function_utils.py
CHANGED
@@ -1,37 +1,33 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
|
+
import asyncio
|
2
3
|
import inspect
|
3
4
|
import os
|
4
|
-
import
|
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,
|
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
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
64
|
-
|
65
|
-
|
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
|
-
|
79
|
-
|
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
|
-
"""
|
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__(
|
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.
|
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
|
108
|
-
#
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
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
|
123
|
-
module = inspect.getmodule(
|
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.
|
132
|
-
package_paths =
|
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.
|
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.
|
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.
|
187
|
+
self._base_dir = base_dirs[0]
|
147
188
|
self.module_name = module.__spec__.name
|
148
|
-
self.
|
149
|
-
self.
|
150
|
-
self.
|
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.
|
157
|
-
self.module_name = inspect.getmodulename(self.
|
158
|
-
self.
|
159
|
-
self.
|
160
|
-
self.
|
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.
|
164
|
-
self.
|
165
|
-
if serialized:
|
166
|
-
self.
|
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.
|
209
|
+
self._type = FunctionInfoType.NOTEBOOK
|
169
210
|
|
170
|
-
if self.
|
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
|
-
|
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.
|
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
|
-
|
187
|
-
|
188
|
-
|
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
|
-
|
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
|
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.
|
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.
|
327
|
+
if self._type == FunctionInfoType.PACKAGE:
|
216
328
|
if config.get("automount"):
|
217
|
-
return [_Mount.
|
218
|
-
elif self.
|
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.
|
222
|
-
self.
|
223
|
-
remote_path=self.
|
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.
|
337
|
+
condition=entrypoint_only_package_mount_condition(self._file),
|
226
338
|
)
|
227
339
|
]
|
228
|
-
elif self.
|
229
|
-
remote_path = ROOT_DIR / Path(self.
|
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.
|
233
|
-
self.
|
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
|
-
|
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
|
292
|
-
"""
|
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
|
-
|
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
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
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
|
-
|
327
|
-
|
395
|
+
# TODO(gongy): generalize this logic as util for unary streams
|
396
|
+
retries_remaining = 10
|
397
|
+
delay_ms = 1
|
328
398
|
|
329
|
-
|
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
|
-
|
332
|
-
|
333
|
-
|
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
|
-
|
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
|
+
)
|