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/_serialization.py
CHANGED
@@ -1,13 +1,16 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
2
|
import io
|
3
3
|
import pickle
|
4
|
+
import typing
|
5
|
+
from dataclasses import dataclass
|
4
6
|
from typing import Any
|
5
7
|
|
8
|
+
from modal._utils.async_utils import synchronizer
|
6
9
|
from modal_proto import api_pb2
|
7
10
|
|
8
11
|
from ._vendor import cloudpickle
|
9
12
|
from .config import logger
|
10
|
-
from .exception import DeserializationError, InvalidError
|
13
|
+
from .exception import DeserializationError, ExecutionError, InvalidError
|
11
14
|
from .object import Object, _Object
|
12
15
|
|
13
16
|
PICKLE_PROTOCOL = 4 # Support older Python versions.
|
@@ -18,10 +21,31 @@ class Pickler(cloudpickle.Pickler):
|
|
18
21
|
super().__init__(buf, protocol=PICKLE_PROTOCOL)
|
19
22
|
|
20
23
|
def persistent_id(self, obj):
|
24
|
+
from modal.partial_function import PartialFunction
|
25
|
+
|
21
26
|
if isinstance(obj, _Object):
|
22
27
|
flag = "_o"
|
23
28
|
elif isinstance(obj, Object):
|
24
29
|
flag = "o"
|
30
|
+
elif isinstance(obj, PartialFunction):
|
31
|
+
# Special case for PartialObject since it's a synchronicity wrapped object
|
32
|
+
# that's set on serialized classes.
|
33
|
+
# The resulting pickled instance can't be deserialized without this in a
|
34
|
+
# new process, since the original referenced synchronizer will have different
|
35
|
+
# values for `._original_attr` etc.
|
36
|
+
|
37
|
+
impl_object = synchronizer._translate_in(obj)
|
38
|
+
attributes = impl_object.__dict__.copy()
|
39
|
+
# ugly - we remove the `._wrapped_attr` attribute from the implementation instance
|
40
|
+
# to avoid referencing and therefore pickling the wrapped instance despite having
|
41
|
+
# translated it to the implementation type
|
42
|
+
|
43
|
+
# it would be nice if we could avoid this by not having the wrapped instances
|
44
|
+
# be directly linked from objects and instead having a lookup table in the Synchronizer:
|
45
|
+
if synchronizer._wrapped_attr and synchronizer._wrapped_attr in attributes:
|
46
|
+
attributes.pop(synchronizer._wrapped_attr)
|
47
|
+
|
48
|
+
return ("sync", (impl_object.__class__, attributes))
|
25
49
|
else:
|
26
50
|
return
|
27
51
|
if not obj.object_id:
|
@@ -35,8 +59,20 @@ class Unpickler(pickle.Unpickler):
|
|
35
59
|
super().__init__(buf)
|
36
60
|
|
37
61
|
def persistent_load(self, pid):
|
62
|
+
if len(pid) == 2:
|
63
|
+
# more general protocol
|
64
|
+
obj_type, obj_data = pid
|
65
|
+
if obj_type == "sync": # synchronicity wrapped object
|
66
|
+
# not actually a proto object in this case but the underlying object of a synchronicity object
|
67
|
+
impl_class, attributes = obj_data
|
68
|
+
impl_instance = impl_class.__new__(impl_class)
|
69
|
+
impl_instance.__dict__.update(attributes)
|
70
|
+
return synchronizer._translate_out(impl_instance)
|
71
|
+
else:
|
72
|
+
raise ExecutionError("Unknown serialization format")
|
73
|
+
|
74
|
+
# old protocol, always a 3-tuple
|
38
75
|
(object_id, flag, handle_proto) = pid
|
39
|
-
|
40
76
|
if flag in ("o", "p", "h"):
|
41
77
|
return Object._new_hydrated(object_id, self.client, handle_proto)
|
42
78
|
elif flag in ("_o", "_p", "_h"):
|
@@ -54,6 +90,9 @@ def serialize(obj: Any) -> bytes:
|
|
54
90
|
|
55
91
|
def deserialize(s: bytes, client) -> Any:
|
56
92
|
"""Deserializes object and replaces all client placeholders by self."""
|
93
|
+
from ._runtime.execution_context import is_local # Avoid circular import
|
94
|
+
|
95
|
+
env = "local" if is_local() else "remote"
|
57
96
|
try:
|
58
97
|
return Unpickler(client, io.BytesIO(s)).load()
|
59
98
|
except AttributeError as exc:
|
@@ -72,11 +111,19 @@ def deserialize(s: bytes, client) -> Any:
|
|
72
111
|
" you have different versions of a library in your local and remote environments."
|
73
112
|
) from exc
|
74
113
|
except ModuleNotFoundError as exc:
|
75
|
-
from .execution_context import is_local # Avoid circular import
|
76
|
-
|
77
|
-
dest = "local" if is_local() else "remote"
|
78
114
|
raise DeserializationError(
|
79
|
-
f"Deserialization failed because the '{exc.name}' module is not available in the {
|
115
|
+
f"Deserialization failed because the '{exc.name}' module is not available in the {env} environment."
|
116
|
+
) from exc
|
117
|
+
except Exception as exc:
|
118
|
+
if env == "remote":
|
119
|
+
# We currently don't always package the full traceback from errors in the remote entrypoint logic.
|
120
|
+
# So try to include as much information as we can in the main error message.
|
121
|
+
more = f": {type(exc)}({str(exc)})"
|
122
|
+
else:
|
123
|
+
# When running locally, we can just rely on standard exception chaining.
|
124
|
+
more = " (see above for details)"
|
125
|
+
raise DeserializationError(
|
126
|
+
f"Encountered an error when deserializing an object in the {env} environment{more}."
|
80
127
|
) from exc
|
81
128
|
|
82
129
|
|
@@ -336,3 +383,73 @@ def check_valid_cls_constructor_arg(key, obj):
|
|
336
383
|
raise ValueError(
|
337
384
|
f"Only pickle-able types are allowed in remote class constructors: argument {key} of type {type(obj)}."
|
338
385
|
)
|
386
|
+
|
387
|
+
|
388
|
+
@dataclass
|
389
|
+
class ParamTypeInfo:
|
390
|
+
default_field: str
|
391
|
+
proto_field: str
|
392
|
+
converter: typing.Callable[[str], typing.Any]
|
393
|
+
|
394
|
+
|
395
|
+
PARAM_TYPE_MAPPING = {
|
396
|
+
api_pb2.PARAM_TYPE_STRING: ParamTypeInfo(default_field="string_default", proto_field="string_value", converter=str),
|
397
|
+
api_pb2.PARAM_TYPE_INT: ParamTypeInfo(default_field="int_default", proto_field="int_value", converter=int),
|
398
|
+
}
|
399
|
+
|
400
|
+
|
401
|
+
def serialize_proto_params(python_params: dict[str, Any], schema: typing.Sequence[api_pb2.ClassParameterSpec]) -> bytes:
|
402
|
+
proto_params: list[api_pb2.ClassParameterValue] = []
|
403
|
+
for schema_param in schema:
|
404
|
+
type_info = PARAM_TYPE_MAPPING.get(schema_param.type)
|
405
|
+
if not type_info:
|
406
|
+
raise ValueError(f"Unsupported parameter type: {schema_param.type}")
|
407
|
+
proto_param = api_pb2.ClassParameterValue(
|
408
|
+
name=schema_param.name,
|
409
|
+
type=schema_param.type,
|
410
|
+
)
|
411
|
+
python_value = python_params.get(schema_param.name)
|
412
|
+
if python_value is None:
|
413
|
+
if schema_param.has_default:
|
414
|
+
python_value = getattr(schema_param, type_info.default_field)
|
415
|
+
else:
|
416
|
+
raise ValueError(f"Missing required parameter: {schema_param.name}")
|
417
|
+
try:
|
418
|
+
converted_value = type_info.converter(python_value)
|
419
|
+
except ValueError as exc:
|
420
|
+
raise ValueError(f"Invalid type for parameter {schema_param.name}: {exc}")
|
421
|
+
setattr(proto_param, type_info.proto_field, converted_value)
|
422
|
+
proto_params.append(proto_param)
|
423
|
+
proto_bytes = api_pb2.ClassParameterSet(parameters=proto_params).SerializeToString(deterministic=True)
|
424
|
+
return proto_bytes
|
425
|
+
|
426
|
+
|
427
|
+
def deserialize_proto_params(serialized_params: bytes, schema: list[api_pb2.ClassParameterSpec]) -> dict[str, Any]:
|
428
|
+
proto_struct = api_pb2.ClassParameterSet()
|
429
|
+
proto_struct.ParseFromString(serialized_params)
|
430
|
+
value_by_name = {p.name: p for p in proto_struct.parameters}
|
431
|
+
python_params = {}
|
432
|
+
for schema_param in schema:
|
433
|
+
if schema_param.name not in value_by_name:
|
434
|
+
# TODO: handle default values? Could just be a flag on the FunctionParameter schema spec,
|
435
|
+
# allowing it to not be supplied in the FunctionParameterSet?
|
436
|
+
raise AttributeError(f"Constructor arguments don't match declared parameters (missing {schema_param.name})")
|
437
|
+
param_value = value_by_name[schema_param.name]
|
438
|
+
if schema_param.type != param_value.type:
|
439
|
+
raise ValueError(
|
440
|
+
"Constructor arguments types don't match declared parameters "
|
441
|
+
f"({schema_param.name}: type {schema_param.type} != type {param_value.type})"
|
442
|
+
)
|
443
|
+
python_value: Any
|
444
|
+
if schema_param.type == api_pb2.PARAM_TYPE_STRING:
|
445
|
+
python_value = param_value.string_value
|
446
|
+
elif schema_param.type == api_pb2.PARAM_TYPE_INT:
|
447
|
+
python_value = param_value.int_value
|
448
|
+
else:
|
449
|
+
# TODO(elias): based on `parameters` declared types, we could add support for
|
450
|
+
# custom non proto types encoded as bytes in the proto, e.g. PARAM_TYPE_PYTHON_PICKLE
|
451
|
+
raise NotImplementedError("Only strings and ints are supported parameter value types at the moment")
|
452
|
+
|
453
|
+
python_params[schema_param.name] = python_value
|
454
|
+
|
455
|
+
return python_params
|
modal/_traceback.py
CHANGED
@@ -1,24 +1,27 @@
|
|
1
1
|
# Copyright Modal Labs 2022
|
2
|
-
|
2
|
+
"""Helper functions related to operating on exceptions, warnings, and traceback objects.
|
3
|
+
|
4
|
+
Functions related to *displaying* tracebacks should go in `modal/cli/_traceback.py`
|
5
|
+
so that Rich is not a dependency of the container Client.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import re
|
9
|
+
import sys
|
3
10
|
import traceback
|
4
11
|
import warnings
|
5
12
|
from types import TracebackType
|
6
|
-
from typing import Any,
|
13
|
+
from typing import Any, Iterable, Optional
|
7
14
|
|
8
|
-
from
|
9
|
-
from rich.panel import Panel
|
10
|
-
from rich.syntax import Syntax
|
11
|
-
from rich.text import Text
|
12
|
-
from rich.traceback import PathHighlighter, Stack, Traceback, install
|
15
|
+
from modal_proto import api_pb2
|
13
16
|
|
14
17
|
from ._vendor.tblib import Traceback as TBLibTraceback
|
15
|
-
from .exception import
|
18
|
+
from .exception import ServerWarning
|
16
19
|
|
17
|
-
TBDictType =
|
18
|
-
LineCacheType =
|
20
|
+
TBDictType = dict[str, Any]
|
21
|
+
LineCacheType = dict[tuple[str, str], str]
|
19
22
|
|
20
23
|
|
21
|
-
def extract_traceback(exc: BaseException, task_id: str) ->
|
24
|
+
def extract_traceback(exc: BaseException, task_id: str) -> tuple[TBDictType, LineCacheType]:
|
22
25
|
"""Given an exception, extract a serializable traceback (with task ID markers included),
|
23
26
|
and a line cache that maps (filename, lineno) to line contents. The latter is used to show
|
24
27
|
a helpful traceback to the user, even if they don't have packages installed locally that
|
@@ -37,6 +40,8 @@ def extract_traceback(exc: BaseException, task_id: str) -> Tuple[TBDictType, Lin
|
|
37
40
|
# container. This means we've reached the end of the local traceback.
|
38
41
|
if file.startswith("<"):
|
39
42
|
break
|
43
|
+
# We rely on this specific filename format when inferring where the exception was raised
|
44
|
+
# in various other exception-related code
|
40
45
|
cur.tb_frame.f_code.co_filename = f"<{task_id}>:{file}"
|
41
46
|
cur = cur.tb_next
|
42
47
|
|
@@ -67,13 +72,17 @@ def append_modal_tb(exc: BaseException, tb_dict: TBDictType, line_cache: LineCac
|
|
67
72
|
setattr(exc, "__line_cache__", line_cache)
|
68
73
|
|
69
74
|
|
70
|
-
def reduce_traceback_to_user_code(tb: TracebackType, user_source: str) -> TracebackType:
|
75
|
+
def reduce_traceback_to_user_code(tb: Optional[TracebackType], user_source: str) -> TracebackType:
|
71
76
|
"""Return a traceback that does not contain modal entrypoint or synchronicity frames."""
|
72
|
-
|
77
|
+
|
78
|
+
# Step forward all the way through the traceback and drop any "Modal support" frames
|
79
|
+
def skip_frame(filename: str) -> bool:
|
80
|
+
return "/site-packages/synchronicity/" in filename or "modal/_utils/deprecation" in filename
|
81
|
+
|
73
82
|
tb_root = tb
|
74
83
|
while tb is not None:
|
75
84
|
while tb.tb_next is not None:
|
76
|
-
if
|
85
|
+
if skip_frame(tb.tb_next.tb_frame.f_code.co_filename):
|
77
86
|
tb.tb_next = tb.tb_next.tb_next
|
78
87
|
else:
|
79
88
|
break
|
@@ -94,176 +103,27 @@ def reduce_traceback_to_user_code(tb: TracebackType, user_source: str) -> Traceb
|
|
94
103
|
return tb
|
95
104
|
|
96
105
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
)
|
122
|
-
|
123
|
-
excluded = False
|
124
|
-
for frame_index, frame in enumerate(stack.frames):
|
125
|
-
if exclude_frames and frame_index in exclude_frames:
|
126
|
-
excluded = True
|
127
|
-
continue
|
128
|
-
|
129
|
-
if excluded:
|
130
|
-
assert exclude_frames is not None
|
131
|
-
yield Text(
|
132
|
-
f"\n... {len(exclude_frames)} frames hidden ...",
|
133
|
-
justify="center",
|
134
|
-
style="traceback.error",
|
135
|
-
)
|
136
|
-
excluded = False
|
137
|
-
|
138
|
-
first = frame_index == 0
|
139
|
-
# Patched Modal-specific code.
|
140
|
-
if frame.filename.startswith("<") and ":" in frame.filename:
|
141
|
-
next_task_id, frame_filename = frame.filename.split(":", 1)
|
142
|
-
next_task_id = next_task_id.strip("<>")
|
143
|
-
else:
|
144
|
-
frame_filename = frame.filename
|
145
|
-
next_task_id = None
|
146
|
-
suppressed = any(frame_filename.startswith(path) for path in self.suppress)
|
147
|
-
|
148
|
-
if next_task_id != task_id:
|
149
|
-
task_id = next_task_id
|
150
|
-
yield ""
|
151
|
-
yield Text(
|
152
|
-
f"...Remote call to Modal Function ({task_id})...",
|
153
|
-
justify="center",
|
154
|
-
style="green",
|
155
|
-
)
|
156
|
-
|
157
|
-
text = Text.assemble(
|
158
|
-
path_highlighter(Text(frame_filename, style="pygments.string")),
|
159
|
-
(":", "pygments.text"),
|
160
|
-
(str(frame.lineno), "pygments.number"),
|
161
|
-
" in ",
|
162
|
-
(frame.name, "pygments.function"),
|
163
|
-
style="pygments.text",
|
164
|
-
)
|
165
|
-
if not frame_filename.startswith("<") and not first:
|
166
|
-
yield ""
|
167
|
-
|
168
|
-
yield text
|
169
|
-
if not suppressed:
|
170
|
-
try:
|
171
|
-
code = read_code(frame_filename)
|
172
|
-
lexer_name = self._guess_lexer(frame_filename, code)
|
173
|
-
syntax = Syntax(
|
174
|
-
code,
|
175
|
-
lexer_name,
|
176
|
-
theme=theme,
|
177
|
-
line_numbers=True,
|
178
|
-
line_range=(
|
179
|
-
frame.lineno - self.extra_lines,
|
180
|
-
frame.lineno + self.extra_lines,
|
181
|
-
),
|
182
|
-
highlight_lines={frame.lineno},
|
183
|
-
word_wrap=self.word_wrap,
|
184
|
-
code_width=88,
|
185
|
-
indent_guides=self.indent_guides,
|
186
|
-
dedent=False,
|
187
|
-
)
|
188
|
-
yield ""
|
189
|
-
except Exception as error:
|
190
|
-
# Patched Modal-specific code.
|
191
|
-
line = line_cache.get((frame_filename, frame.lineno))
|
192
|
-
if line:
|
193
|
-
try:
|
194
|
-
lexer_name = self._guess_lexer(frame_filename, line)
|
195
|
-
yield ""
|
196
|
-
yield Syntax(
|
197
|
-
line,
|
198
|
-
lexer_name,
|
199
|
-
theme=theme,
|
200
|
-
line_numbers=True,
|
201
|
-
line_range=(0, 1),
|
202
|
-
highlight_lines={frame.lineno},
|
203
|
-
word_wrap=self.word_wrap,
|
204
|
-
code_width=88,
|
205
|
-
indent_guides=self.indent_guides,
|
206
|
-
dedent=False,
|
207
|
-
start_line=frame.lineno,
|
208
|
-
)
|
209
|
-
except Exception:
|
210
|
-
yield Text.assemble(
|
211
|
-
(f"\n{error}", "traceback.error"),
|
212
|
-
)
|
213
|
-
yield ""
|
214
|
-
else:
|
215
|
-
yield syntax
|
216
|
-
|
217
|
-
|
218
|
-
def setup_rich_traceback() -> None:
|
219
|
-
from_exception = Traceback.from_exception
|
220
|
-
|
221
|
-
@functools.wraps(Traceback.from_exception)
|
222
|
-
def _from_exception(exc_type, exc_value, *args, **kwargs):
|
223
|
-
"""Patch from_exception to grab the Modal line_cache and store it with the
|
224
|
-
Stack object, so it's available to render_stack at display time."""
|
225
|
-
|
226
|
-
line_cache = getattr(exc_value, "__line_cache__", {})
|
227
|
-
tb = from_exception(exc_type, exc_value, *args, **kwargs)
|
228
|
-
for stack in tb.trace.stacks:
|
229
|
-
stack.line_cache = line_cache # type: ignore
|
230
|
-
return tb
|
231
|
-
|
232
|
-
Traceback._render_stack = _render_stack # type: ignore
|
233
|
-
Traceback.from_exception = _from_exception # type: ignore
|
234
|
-
|
235
|
-
import click
|
236
|
-
import grpclib
|
237
|
-
import synchronicity
|
238
|
-
import typer
|
239
|
-
|
240
|
-
install(suppress=[synchronicity, grpclib, click, typer], extra_lines=1)
|
241
|
-
|
242
|
-
|
243
|
-
def highlight_modal_deprecation_warnings() -> None:
|
244
|
-
"""Patch the warnings module to make client deprecation warnings more salient in the CLI."""
|
245
|
-
base_showwarning = warnings.showwarning
|
246
|
-
|
247
|
-
def showwarning(warning, category, filename, lineno, file=None, line=None):
|
248
|
-
if issubclass(category, DeprecationError):
|
249
|
-
content = str(warning)
|
250
|
-
date = content[:10]
|
251
|
-
message = content[11:].strip()
|
252
|
-
try:
|
253
|
-
with open(filename, "rt", encoding="utf-8", errors="replace") as code_file:
|
254
|
-
source = code_file.readlines()[lineno - 1].strip()
|
255
|
-
message = f"{message}\n\nSource: {filename}:{lineno}\n {source}"
|
256
|
-
except OSError:
|
257
|
-
# e.g., when filename is "<unknown>"; raises FileNotFoundError on posix but OSError on windows
|
258
|
-
pass
|
259
|
-
panel = Panel(
|
260
|
-
message,
|
261
|
-
style="yellow",
|
262
|
-
title=f"Modal Deprecation Warning ({date})",
|
263
|
-
title_align="left",
|
264
|
-
)
|
265
|
-
Console().print(panel)
|
266
|
-
else:
|
267
|
-
base_showwarning(warning, category, filename, lineno, file=None, line=None)
|
268
|
-
|
269
|
-
warnings.showwarning = showwarning
|
106
|
+
def traceback_contains_remote_call(tb: Optional[TracebackType]) -> bool:
|
107
|
+
"""Inspect the traceback stack to determine whether an error was raised locally or remotely."""
|
108
|
+
while tb is not None:
|
109
|
+
if re.match(r"^<ta-[0-9A-Z]{26}>:", tb.tb_frame.f_code.co_filename):
|
110
|
+
return True
|
111
|
+
tb = tb.tb_next
|
112
|
+
return False
|
113
|
+
|
114
|
+
|
115
|
+
def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseException], tb: Optional[TracebackType]):
|
116
|
+
"""Add backwards compatibility for printing exceptions with "notes" for Python<3.11."""
|
117
|
+
traceback.print_exception(exc, value, tb)
|
118
|
+
if sys.version_info < (3, 11) and value is not None:
|
119
|
+
notes = getattr(value, "__notes__", [])
|
120
|
+
print(*notes, sep="\n", file=sys.stderr)
|
121
|
+
|
122
|
+
|
123
|
+
def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
|
124
|
+
"""Issue a warning originating from the server with empty metadata about local origin.
|
125
|
+
|
126
|
+
When using the Modal CLI, these warnings should get caught and coerced into Rich panels.
|
127
|
+
"""
|
128
|
+
for warning in server_warnings:
|
129
|
+
warnings.warn_explicit(warning.message, ServerWarning, "<modal-server>", 0)
|
modal/_tunnel.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
# Copyright Modal Labs 2023
|
2
2
|
"""Client for Modal relay servers, allowing users to expose TLS."""
|
3
3
|
|
4
|
+
from collections.abc import AsyncIterator
|
4
5
|
from dataclasses import dataclass
|
5
|
-
from typing import
|
6
|
+
from typing import Optional
|
6
7
|
|
7
8
|
from grpclib import GRPCError, Status
|
8
9
|
from synchronicity.async_wrap import asynccontextmanager
|
@@ -35,12 +36,12 @@ class Tunnel:
|
|
35
36
|
return value
|
36
37
|
|
37
38
|
@property
|
38
|
-
def tls_socket(self) ->
|
39
|
+
def tls_socket(self) -> tuple[str, int]:
|
39
40
|
"""Get the public TLS socket as a (host, port) tuple."""
|
40
41
|
return (self.host, self.port)
|
41
42
|
|
42
43
|
@property
|
43
|
-
def tcp_socket(self) ->
|
44
|
+
def tcp_socket(self) -> tuple[str, int]:
|
44
45
|
"""Get the public TCP socket as a (host, port) tuple."""
|
45
46
|
if not self.unencrypted_host:
|
46
47
|
raise InvalidError(
|
@@ -54,22 +55,22 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
54
55
|
"""Expose a port publicly from inside a running Modal container, with TLS.
|
55
56
|
|
56
57
|
If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
|
57
|
-
number. This can be used to SSH into a container. Note that it is on the public Internet, so
|
58
|
+
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
58
59
|
make sure you are using a secure protocol over TCP.
|
59
60
|
|
60
61
|
**Important:** This is an experimental API which may change in the future.
|
61
62
|
|
62
63
|
**Usage:**
|
63
64
|
|
64
|
-
```python
|
65
|
+
```python notest
|
66
|
+
import modal
|
65
67
|
from flask import Flask
|
66
|
-
from modal import Image, App, forward
|
67
68
|
|
68
|
-
app = App(image=Image.debian_slim().pip_install("Flask"))
|
69
|
-
|
69
|
+
app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
|
70
|
+
flask_app = Flask(__name__)
|
70
71
|
|
71
72
|
|
72
|
-
@
|
73
|
+
@flask_app.route("/")
|
73
74
|
def hello_world():
|
74
75
|
return "Hello, World!"
|
75
76
|
|
@@ -78,9 +79,9 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
78
79
|
def run_app():
|
79
80
|
# Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
|
80
81
|
# expose that port to the world at a random HTTPS URL.
|
81
|
-
with forward(8000) as tunnel:
|
82
|
+
with modal.forward(8000) as tunnel:
|
82
83
|
print("Server listening at", tunnel.url)
|
83
|
-
|
84
|
+
flask_app.run("0.0.0.0", 8000)
|
84
85
|
|
85
86
|
# When the context manager exits, the port is no longer exposed.
|
86
87
|
```
|
@@ -90,7 +91,8 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
90
91
|
```python
|
91
92
|
import socket
|
92
93
|
import threading
|
93
|
-
|
94
|
+
|
95
|
+
import modal
|
94
96
|
|
95
97
|
|
96
98
|
def run_echo_server(port: int):
|
@@ -115,17 +117,51 @@ async def _forward(port: int, *, unencrypted: bool = False, client: Optional[_Cl
|
|
115
117
|
threading.Thread(target=handle, args=(conn,)).start()
|
116
118
|
|
117
119
|
|
118
|
-
app = App()
|
120
|
+
app = modal.App()
|
119
121
|
|
120
122
|
|
121
123
|
@app.function()
|
122
124
|
def tcp_tunnel():
|
123
125
|
# This exposes port 8000 to public Internet traffic over TCP.
|
124
|
-
with forward(8000, unencrypted=True) as tunnel:
|
126
|
+
with modal.forward(8000, unencrypted=True) as tunnel:
|
125
127
|
# You can connect to this TCP socket from outside the container, for example, using `nc`:
|
126
128
|
# nc <HOST> <PORT>
|
127
129
|
print("TCP tunnel listening at:", tunnel.tcp_socket)
|
128
130
|
run_echo_server(8000)
|
131
|
+
```
|
132
|
+
|
133
|
+
**SSH example:**
|
134
|
+
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
|
135
|
+
letting you SSH into a Modal container.
|
136
|
+
|
137
|
+
```python
|
138
|
+
import subprocess
|
139
|
+
import time
|
140
|
+
|
141
|
+
import modal
|
142
|
+
|
143
|
+
app = modal.App()
|
144
|
+
image = (
|
145
|
+
modal.Image.debian_slim()
|
146
|
+
.apt_install("openssh-server")
|
147
|
+
.run_commands("mkdir /run/sshd")
|
148
|
+
.copy_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys")
|
149
|
+
)
|
150
|
+
|
151
|
+
|
152
|
+
@app.function(image=image, timeout=3600)
|
153
|
+
def some_function():
|
154
|
+
subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
|
155
|
+
with modal.forward(port=22, unencrypted=True) as tunnel:
|
156
|
+
hostname, port = tunnel.tcp_socket
|
157
|
+
connection_cmd = f'ssh -p {port} root@{hostname}'
|
158
|
+
print(f"ssh into container using: {connection_cmd}")
|
159
|
+
time.sleep(3600) # keep alive for 1 hour or until killed
|
160
|
+
```
|
161
|
+
|
162
|
+
If you intend to use this more generally, a suggestion is to put the subprocess and port
|
163
|
+
forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
|
164
|
+
ssh server and port for each container (and not one for each input to the function).
|
129
165
|
"""
|
130
166
|
|
131
167
|
if not isinstance(port, int):
|
modal/_tunnel.pyi
CHANGED
@@ -10,45 +10,28 @@ class Tunnel:
|
|
10
10
|
unencrypted_port: int
|
11
11
|
|
12
12
|
@property
|
13
|
-
def url(self) -> str:
|
14
|
-
...
|
15
|
-
|
13
|
+
def url(self) -> str: ...
|
16
14
|
@property
|
17
|
-
def tls_socket(self) ->
|
18
|
-
...
|
19
|
-
|
15
|
+
def tls_socket(self) -> tuple[str, int]: ...
|
20
16
|
@property
|
21
|
-
def tcp_socket(self) ->
|
22
|
-
|
23
|
-
|
24
|
-
def
|
25
|
-
|
26
|
-
|
27
|
-
def
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
def __setattr__(self, name, value):
|
34
|
-
...
|
35
|
-
|
36
|
-
def __delattr__(self, name):
|
37
|
-
...
|
38
|
-
|
39
|
-
def __hash__(self):
|
40
|
-
...
|
41
|
-
|
42
|
-
|
43
|
-
def _forward(port: int, *, unencrypted: bool = False, client: typing.Union[modal.client._Client, None] = None) -> typing.AsyncContextManager[Tunnel]:
|
44
|
-
...
|
45
|
-
|
17
|
+
def tcp_socket(self) -> tuple[str, int]: ...
|
18
|
+
def __init__(self, host: str, port: int, unencrypted_host: str, unencrypted_port: int) -> None: ...
|
19
|
+
def __repr__(self): ...
|
20
|
+
def __eq__(self, other): ...
|
21
|
+
def __setattr__(self, name, value): ...
|
22
|
+
def __delattr__(self, name): ...
|
23
|
+
def __hash__(self): ...
|
24
|
+
|
25
|
+
def _forward(
|
26
|
+
port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client._Client] = None
|
27
|
+
) -> typing.AsyncContextManager[Tunnel]: ...
|
46
28
|
|
47
29
|
class __forward_spec(typing_extensions.Protocol):
|
48
|
-
def __call__(
|
49
|
-
|
50
|
-
|
51
|
-
def aio(
|
52
|
-
|
30
|
+
def __call__(
|
31
|
+
self, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
|
32
|
+
) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]: ...
|
33
|
+
def aio(
|
34
|
+
self, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
|
35
|
+
) -> typing.AsyncContextManager[Tunnel]: ...
|
53
36
|
|
54
37
|
forward: __forward_spec
|
modal/_utils/app_utils.py
CHANGED
@@ -1,17 +1,3 @@
|
|
1
|
-
# Copyright Modal Labs
|
2
|
-
|
3
|
-
|
4
|
-
# https://www.rfc-editor.org/rfc/rfc1035
|
5
|
-
subdomain_regex = re.compile("^(?![0-9]+$)(?!-)[a-z0-9-]{,63}(?<!-)$")
|
6
|
-
|
7
|
-
|
8
|
-
def is_valid_subdomain_label(label: str):
|
9
|
-
return subdomain_regex.match(label) is not None
|
10
|
-
|
11
|
-
|
12
|
-
def replace_invalid_subdomain_chars(label: str):
|
13
|
-
return re.sub("[^a-z0-9-]", "-", label.lower())
|
14
|
-
|
15
|
-
|
16
|
-
def is_valid_app_name(name: str):
|
17
|
-
return len(name) <= 64 and re.match("^[a-zA-Z0-9-_.]+$", name) is not None
|
1
|
+
# Copyright Modal Labs 2024
|
2
|
+
# Temporary shim as we use this in the server
|
3
|
+
from .name_utils import * # noqa
|