modal 1.0.3.dev10__py3-none-any.whl → 1.2.3.dev7__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/__init__.py +0 -2
- modal/__main__.py +3 -4
- modal/_billing.py +80 -0
- modal/_clustered_functions.py +7 -3
- modal/_clustered_functions.pyi +15 -3
- modal/_container_entrypoint.py +51 -69
- modal/_functions.py +508 -240
- modal/_grpc_client.py +171 -0
- modal/_load_context.py +105 -0
- modal/_object.py +81 -21
- modal/_output.py +58 -45
- modal/_partial_function.py +48 -73
- modal/_pty.py +7 -3
- modal/_resolver.py +26 -46
- modal/_runtime/asgi.py +4 -3
- modal/_runtime/container_io_manager.py +358 -220
- modal/_runtime/container_io_manager.pyi +296 -101
- modal/_runtime/execution_context.py +18 -2
- modal/_runtime/execution_context.pyi +64 -7
- modal/_runtime/gpu_memory_snapshot.py +262 -57
- modal/_runtime/user_code_imports.py +28 -58
- modal/_serialization.py +90 -6
- modal/_traceback.py +42 -1
- modal/_tunnel.pyi +380 -12
- modal/_utils/async_utils.py +84 -29
- modal/_utils/auth_token_manager.py +111 -0
- modal/_utils/blob_utils.py +181 -58
- modal/_utils/deprecation.py +19 -0
- modal/_utils/function_utils.py +91 -47
- modal/_utils/grpc_utils.py +89 -66
- modal/_utils/mount_utils.py +26 -1
- modal/_utils/name_utils.py +17 -3
- modal/_utils/task_command_router_client.py +536 -0
- modal/_utils/time_utils.py +34 -6
- modal/app.py +256 -88
- modal/app.pyi +909 -92
- modal/billing.py +5 -0
- modal/builder/2025.06.txt +18 -0
- modal/builder/PREVIEW.txt +18 -0
- modal/builder/base-images.json +58 -0
- modal/cli/_download.py +19 -3
- modal/cli/_traceback.py +3 -2
- modal/cli/app.py +4 -4
- modal/cli/cluster.py +15 -7
- modal/cli/config.py +5 -3
- modal/cli/container.py +7 -6
- modal/cli/dict.py +22 -16
- modal/cli/entry_point.py +12 -5
- modal/cli/environment.py +5 -4
- modal/cli/import_refs.py +3 -3
- modal/cli/launch.py +102 -5
- modal/cli/network_file_system.py +11 -12
- modal/cli/profile.py +3 -2
- modal/cli/programs/launch_instance_ssh.py +94 -0
- modal/cli/programs/run_jupyter.py +1 -1
- modal/cli/programs/run_marimo.py +95 -0
- modal/cli/programs/vscode.py +1 -1
- modal/cli/queues.py +57 -26
- modal/cli/run.py +91 -23
- modal/cli/secret.py +48 -22
- modal/cli/token.py +7 -8
- modal/cli/utils.py +4 -7
- modal/cli/volume.py +31 -25
- modal/client.py +15 -85
- modal/client.pyi +183 -62
- modal/cloud_bucket_mount.py +5 -3
- modal/cloud_bucket_mount.pyi +197 -5
- modal/cls.py +200 -126
- modal/cls.pyi +446 -68
- modal/config.py +29 -11
- modal/container_process.py +319 -19
- modal/container_process.pyi +190 -20
- modal/dict.py +290 -71
- modal/dict.pyi +835 -83
- modal/environments.py +15 -27
- modal/environments.pyi +46 -24
- modal/exception.py +14 -2
- modal/experimental/__init__.py +194 -40
- modal/experimental/flash.py +618 -0
- modal/experimental/flash.pyi +380 -0
- modal/experimental/ipython.py +11 -7
- modal/file_io.py +29 -36
- modal/file_io.pyi +251 -53
- modal/file_pattern_matcher.py +56 -16
- modal/functions.pyi +673 -92
- modal/gpu.py +1 -1
- modal/image.py +528 -176
- modal/image.pyi +1572 -145
- modal/io_streams.py +458 -128
- modal/io_streams.pyi +433 -52
- modal/mount.py +216 -151
- modal/mount.pyi +225 -78
- modal/network_file_system.py +45 -62
- modal/network_file_system.pyi +277 -56
- modal/object.pyi +93 -17
- modal/parallel_map.py +942 -129
- modal/parallel_map.pyi +294 -15
- modal/partial_function.py +0 -2
- modal/partial_function.pyi +234 -19
- modal/proxy.py +17 -8
- modal/proxy.pyi +36 -3
- modal/queue.py +270 -65
- modal/queue.pyi +817 -57
- modal/runner.py +115 -101
- modal/runner.pyi +205 -49
- modal/sandbox.py +512 -136
- modal/sandbox.pyi +845 -111
- modal/schedule.py +1 -1
- modal/secret.py +300 -70
- modal/secret.pyi +589 -34
- modal/serving.py +7 -11
- modal/serving.pyi +7 -8
- modal/snapshot.py +11 -8
- modal/snapshot.pyi +25 -4
- modal/token_flow.py +4 -4
- modal/token_flow.pyi +28 -8
- modal/volume.py +416 -158
- modal/volume.pyi +1117 -121
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/METADATA +10 -9
- modal-1.2.3.dev7.dist-info/RECORD +195 -0
- modal_docs/mdmd/mdmd.py +17 -4
- modal_proto/api.proto +534 -79
- modal_proto/api_grpc.py +337 -1
- modal_proto/api_pb2.py +1522 -968
- modal_proto/api_pb2.pyi +1619 -134
- modal_proto/api_pb2_grpc.py +699 -4
- modal_proto/api_pb2_grpc.pyi +226 -14
- modal_proto/modal_api_grpc.py +175 -154
- modal_proto/sandbox_router.proto +145 -0
- modal_proto/sandbox_router_grpc.py +105 -0
- modal_proto/sandbox_router_pb2.py +149 -0
- modal_proto/sandbox_router_pb2.pyi +333 -0
- modal_proto/sandbox_router_pb2_grpc.py +203 -0
- modal_proto/sandbox_router_pb2_grpc.pyi +75 -0
- modal_proto/task_command_router.proto +144 -0
- modal_proto/task_command_router_grpc.py +105 -0
- modal_proto/task_command_router_pb2.py +149 -0
- modal_proto/task_command_router_pb2.pyi +333 -0
- modal_proto/task_command_router_pb2_grpc.py +203 -0
- modal_proto/task_command_router_pb2_grpc.pyi +75 -0
- modal_version/__init__.py +1 -1
- modal/requirements/PREVIEW.txt +0 -16
- modal/requirements/base-images.json +0 -26
- modal-1.0.3.dev10.dist-info/RECORD +0 -179
- modal_proto/modal_options_grpc.py +0 -3
- modal_proto/options.proto +0 -19
- modal_proto/options_grpc.py +0 -3
- modal_proto/options_pb2.py +0 -35
- modal_proto/options_pb2.pyi +0 -20
- modal_proto/options_pb2_grpc.py +0 -4
- modal_proto/options_pb2_grpc.pyi +0 -7
- /modal/{requirements → builder}/2023.12.312.txt +0 -0
- /modal/{requirements → builder}/2023.12.txt +0 -0
- /modal/{requirements → builder}/2024.04.txt +0 -0
- /modal/{requirements → builder}/2024.10.txt +0 -0
- /modal/{requirements → builder}/README.md +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/WHEEL +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/entry_points.txt +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/licenses/LICENSE +0 -0
- {modal-1.0.3.dev10.dist-info → modal-1.2.3.dev7.dist-info}/top_level.txt +0 -0
modal/_serialization.py
CHANGED
|
@@ -6,14 +6,24 @@ import typing
|
|
|
6
6
|
from inspect import Parameter
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from modal._traceback import extract_traceback
|
|
10
|
+
from modal.config import config
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import cbor2 # type: ignore
|
|
14
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
15
|
+
cbor2 = None
|
|
16
|
+
|
|
17
|
+
import google.protobuf.message
|
|
18
|
+
|
|
9
19
|
from modal._utils.async_utils import synchronizer
|
|
10
20
|
from modal_proto import api_pb2
|
|
11
21
|
|
|
12
22
|
from ._object import _Object
|
|
13
23
|
from ._type_manager import parameter_serde_registry, schema_registry
|
|
14
24
|
from ._vendor import cloudpickle
|
|
15
|
-
from .config import
|
|
16
|
-
from .exception import DeserializationError, ExecutionError, InvalidError
|
|
25
|
+
from .config import logger
|
|
26
|
+
from .exception import DeserializationError, ExecutionError, InvalidError, SerializationError
|
|
17
27
|
from .object import Object
|
|
18
28
|
|
|
19
29
|
if typing.TYPE_CHECKING:
|
|
@@ -344,6 +354,12 @@ def _deserialize_asgi(asgi: api_pb2.Asgi) -> Any:
|
|
|
344
354
|
return None
|
|
345
355
|
|
|
346
356
|
|
|
357
|
+
def get_preferred_payload_format() -> "api_pb2.DataFormat.ValueType":
|
|
358
|
+
payload_format = (config.get("payload_format") or "pickle").lower()
|
|
359
|
+
data_format = api_pb2.DATA_FORMAT_CBOR if payload_format == "cbor" else api_pb2.DATA_FORMAT_PICKLE
|
|
360
|
+
return data_format
|
|
361
|
+
|
|
362
|
+
|
|
347
363
|
def serialize_data_format(obj: Any, data_format: int) -> bytes:
|
|
348
364
|
"""Similar to serialize(), but supports other data formats."""
|
|
349
365
|
if data_format == api_pb2.DATA_FORMAT_PICKLE:
|
|
@@ -353,6 +369,21 @@ def serialize_data_format(obj: Any, data_format: int) -> bytes:
|
|
|
353
369
|
elif data_format == api_pb2.DATA_FORMAT_GENERATOR_DONE:
|
|
354
370
|
assert isinstance(obj, api_pb2.GeneratorDone)
|
|
355
371
|
return obj.SerializeToString(deterministic=True)
|
|
372
|
+
elif data_format == api_pb2.DATA_FORMAT_CBOR:
|
|
373
|
+
if cbor2 is None:
|
|
374
|
+
raise InvalidError("CBOR support requires the 'cbor2' package to be installed.")
|
|
375
|
+
try:
|
|
376
|
+
return cbor2.dumps(obj)
|
|
377
|
+
except cbor2.CBOREncodeTypeError:
|
|
378
|
+
try:
|
|
379
|
+
typename = f"{type(obj).__module__}.{type(obj).__name__}"
|
|
380
|
+
except Exception:
|
|
381
|
+
typename = str(type(obj))
|
|
382
|
+
raise SerializationError(
|
|
383
|
+
# TODO (elias): add documentation link for more information on this
|
|
384
|
+
f"Can not serialize type {typename} as cbor. If you need to use a custom data type, "
|
|
385
|
+
"try to serialize it yourself e.g. by using pickle.dumps(my_data)"
|
|
386
|
+
)
|
|
356
387
|
else:
|
|
357
388
|
raise InvalidError(f"Unknown data format {data_format!r}")
|
|
358
389
|
|
|
@@ -364,6 +395,10 @@ def deserialize_data_format(s: bytes, data_format: int, client) -> Any:
|
|
|
364
395
|
return _deserialize_asgi(api_pb2.Asgi.FromString(s))
|
|
365
396
|
elif data_format == api_pb2.DATA_FORMAT_GENERATOR_DONE:
|
|
366
397
|
return api_pb2.GeneratorDone.FromString(s)
|
|
398
|
+
elif data_format == api_pb2.DATA_FORMAT_CBOR:
|
|
399
|
+
if cbor2 is None:
|
|
400
|
+
raise InvalidError("CBOR support requires the 'cbor2' package to be installed.")
|
|
401
|
+
return cbor2.loads(s)
|
|
367
402
|
else:
|
|
368
403
|
raise InvalidError(f"Unknown data format {data_format!r}")
|
|
369
404
|
|
|
@@ -470,10 +505,31 @@ def deserialize_params(serialized_params: bytes, function_def: api_pb2.Function,
|
|
|
470
505
|
api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PICKLE,
|
|
471
506
|
):
|
|
472
507
|
# legacy serialization format - pickle of `(args, kwargs)` w/ support for modal object arguments
|
|
473
|
-
|
|
508
|
+
try:
|
|
509
|
+
param_args, param_kwargs = deserialize(serialized_params, _client)
|
|
510
|
+
except DeserializationError as original_exc:
|
|
511
|
+
# Fallback in case of proto -> pickle downgrades of a parameter serialization format
|
|
512
|
+
# I.e. FunctionBindParams binding proto serialized params to a function defintion
|
|
513
|
+
# that now assumes pickled data according to class_parameter_info
|
|
514
|
+
param_args = ()
|
|
515
|
+
try:
|
|
516
|
+
param_kwargs = deserialize_proto_params(serialized_params)
|
|
517
|
+
except Exception:
|
|
518
|
+
raise original_exc
|
|
519
|
+
|
|
474
520
|
elif function_def.class_parameter_info.format == api_pb2.ClassParameterInfo.PARAM_SERIALIZATION_FORMAT_PROTO:
|
|
475
521
|
param_args = () # we use kwargs only for our implicit constructors
|
|
476
|
-
|
|
522
|
+
try:
|
|
523
|
+
param_kwargs = deserialize_proto_params(serialized_params)
|
|
524
|
+
except google.protobuf.message.DecodeError as original_exc:
|
|
525
|
+
# Fallback in case of pickle -> proto upgrades of a parameter serialization format
|
|
526
|
+
# I.e. FunctionBindParams binding pickle serialized params to a function defintion
|
|
527
|
+
# that now assumes proto data according to class_parameter_info
|
|
528
|
+
try:
|
|
529
|
+
param_args, param_kwargs = deserialize(serialized_params, _client)
|
|
530
|
+
except Exception:
|
|
531
|
+
raise original_exc
|
|
532
|
+
|
|
477
533
|
else:
|
|
478
534
|
raise ExecutionError(
|
|
479
535
|
f"Unknown class parameter serialization format: {function_def.class_parameter_info.format}"
|
|
@@ -532,11 +588,16 @@ def get_callable_schema(
|
|
|
532
588
|
callable: typing.Callable, *, is_web_endpoint: bool, ignore_first_argument: bool = False
|
|
533
589
|
) -> typing.Optional[api_pb2.FunctionSchema]:
|
|
534
590
|
# ignore_first_argument can be used in case of unbound methods where we want to ignore the first (self) argument
|
|
535
|
-
if is_web_endpoint
|
|
591
|
+
if is_web_endpoint:
|
|
536
592
|
# we don't support schemas on web endpoints for now
|
|
537
593
|
return None
|
|
538
594
|
|
|
539
|
-
|
|
595
|
+
try:
|
|
596
|
+
sig = inspect.signature(callable)
|
|
597
|
+
except Exception as e:
|
|
598
|
+
logger.debug(f"Error getting signature for function {callable}", exc_info=e)
|
|
599
|
+
return None
|
|
600
|
+
|
|
540
601
|
# TODO: treat no return value annotation as None return?
|
|
541
602
|
return_type_proto = schema_registry.get_proto_generic_type(sig.return_annotation)
|
|
542
603
|
arguments = []
|
|
@@ -551,3 +612,26 @@ def get_callable_schema(
|
|
|
551
612
|
arguments=arguments,
|
|
552
613
|
return_type=return_type_proto,
|
|
553
614
|
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def pickle_exception(exc: BaseException) -> bytes:
|
|
618
|
+
try:
|
|
619
|
+
return serialize(exc)
|
|
620
|
+
except Exception as serialization_exc:
|
|
621
|
+
# We can't always serialize exceptions.
|
|
622
|
+
err = f"Failed to serialize exception {exc} of type {type(exc)}: {serialization_exc}"
|
|
623
|
+
logger.info(err)
|
|
624
|
+
return serialize(SerializationError(err))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def pickle_traceback(exc: BaseException, task_id: str) -> tuple[bytes, bytes]:
|
|
628
|
+
serialized_tb, tb_line_cache = b"", b""
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
tb_dict, line_cache = extract_traceback(exc, task_id)
|
|
632
|
+
serialized_tb = serialize(tb_dict)
|
|
633
|
+
tb_line_cache = serialize(line_cache)
|
|
634
|
+
except Exception:
|
|
635
|
+
logger.info("Failed to serialize exception traceback.")
|
|
636
|
+
|
|
637
|
+
return serialized_tb, tb_line_cache
|
modal/_traceback.py
CHANGED
|
@@ -8,10 +8,12 @@ so that Rich is not a dependency of the container Client.
|
|
|
8
8
|
import re
|
|
9
9
|
import sys
|
|
10
10
|
import traceback
|
|
11
|
+
import typing
|
|
11
12
|
import warnings
|
|
12
13
|
from types import TracebackType
|
|
13
14
|
from typing import Any, Iterable, Optional
|
|
14
15
|
|
|
16
|
+
from modal.config import config, logger
|
|
15
17
|
from modal_proto import api_pb2
|
|
16
18
|
|
|
17
19
|
from ._vendor.tblib import Traceback as TBLibTraceback
|
|
@@ -115,7 +117,7 @@ def traceback_contains_remote_call(tb: Optional[TracebackType]) -> bool:
|
|
|
115
117
|
def print_exception(exc: Optional[type[BaseException]], value: Optional[BaseException], tb: Optional[TracebackType]):
|
|
116
118
|
"""Add backwards compatibility for printing exceptions with "notes" for Python<3.11."""
|
|
117
119
|
traceback.print_exception(exc, value, tb)
|
|
118
|
-
if sys.version_info < (3, 11) and value is not None:
|
|
120
|
+
if sys.version_info < (3, 11) and value is not None: # type: ignore
|
|
119
121
|
notes = getattr(value, "__notes__", [])
|
|
120
122
|
print(*notes, sep="\n", file=sys.stderr)
|
|
121
123
|
|
|
@@ -127,3 +129,42 @@ def print_server_warnings(server_warnings: Iterable[api_pb2.Warning]):
|
|
|
127
129
|
"""
|
|
128
130
|
for warning in server_warnings:
|
|
129
131
|
warnings.warn_explicit(warning.message, ServerWarning, "<modal-server>", 0)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# for some reason, the traceback cleanup here can't be moved into a context manager :(
|
|
135
|
+
traceback_suppression_note = (
|
|
136
|
+
"Internal Modal traceback frames are suppressed for readability. Use MODAL_TRACEBACK=1 to show a full traceback."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class suppress_tb_frames:
|
|
141
|
+
def __init__(self, n: int):
|
|
142
|
+
self.n = n
|
|
143
|
+
|
|
144
|
+
def __enter__(self):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def __exit__(
|
|
148
|
+
self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]
|
|
149
|
+
) -> typing.Literal[False]:
|
|
150
|
+
# *base* exceptions like CancelledError, SystemExit etc. can come from random places,
|
|
151
|
+
# so we don't suppress tracebacks for those
|
|
152
|
+
is_base_exception = not isinstance(exc, Exception)
|
|
153
|
+
if config.get("traceback") or exc_type is None or is_base_exception:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
# modify traceback on exception object
|
|
157
|
+
try:
|
|
158
|
+
final_tb = tb
|
|
159
|
+
for _ in range(self.n):
|
|
160
|
+
final_tb = final_tb.tb_next
|
|
161
|
+
except AttributeError:
|
|
162
|
+
logger.debug(f"Failed to suppress {self.n} traceback frames from {str(exc_type)} {str(exc)}")
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
exc.with_traceback(final_tb)
|
|
166
|
+
notes = getattr(exc, "__notes__", [])
|
|
167
|
+
if traceback_suppression_note not in notes:
|
|
168
|
+
# .add_note was added in Python 3.11
|
|
169
|
+
notes.append(traceback_suppression_note)
|
|
170
|
+
return False
|
modal/_tunnel.pyi
CHANGED
|
@@ -4,34 +4,402 @@ import typing
|
|
|
4
4
|
import typing_extensions
|
|
5
5
|
|
|
6
6
|
class Tunnel:
|
|
7
|
+
"""A port forwarded from within a running Modal container. Created by `modal.forward()`.
|
|
8
|
+
|
|
9
|
+
**Important:** This is an experimental API which may change in the future.
|
|
10
|
+
"""
|
|
11
|
+
|
|
7
12
|
host: str
|
|
8
13
|
port: int
|
|
9
14
|
unencrypted_host: str
|
|
10
15
|
unencrypted_port: int
|
|
11
16
|
|
|
12
17
|
@property
|
|
13
|
-
def url(self) -> str:
|
|
18
|
+
def url(self) -> str:
|
|
19
|
+
"""Get the public HTTPS URL of the forwarded port."""
|
|
20
|
+
...
|
|
21
|
+
|
|
14
22
|
@property
|
|
15
|
-
def tls_socket(self) -> tuple[str, int]:
|
|
23
|
+
def tls_socket(self) -> tuple[str, int]:
|
|
24
|
+
"""Get the public TLS socket as a (host, port) tuple."""
|
|
25
|
+
...
|
|
26
|
+
|
|
16
27
|
@property
|
|
17
|
-
def tcp_socket(self) -> tuple[str, int]:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
def tcp_socket(self) -> tuple[str, int]:
|
|
29
|
+
"""Get the public TCP socket as a (host, port) tuple."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def __init__(self, host: str, port: int, unencrypted_host: str, unencrypted_port: int) -> None:
|
|
33
|
+
"""Initialize self. See help(type(self)) for accurate signature."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def __repr__(self):
|
|
37
|
+
"""Return repr(self)."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def __eq__(self, other):
|
|
41
|
+
"""Return self==value."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
def __setattr__(self, name, value):
|
|
45
|
+
"""Implement setattr(self, name, value)."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def __delattr__(self, name):
|
|
49
|
+
"""Implement delattr(self, name)."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def __hash__(self):
|
|
53
|
+
"""Return hash(self)."""
|
|
54
|
+
...
|
|
24
55
|
|
|
25
56
|
def _forward(
|
|
26
57
|
port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client._Client] = None
|
|
27
|
-
) -> typing.AsyncContextManager[Tunnel]:
|
|
58
|
+
) -> typing.AsyncContextManager[Tunnel]:
|
|
59
|
+
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
60
|
+
|
|
61
|
+
If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
|
|
62
|
+
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
63
|
+
make sure you are using a secure protocol over TCP.
|
|
64
|
+
|
|
65
|
+
**Important:** This is an experimental API which may change in the future.
|
|
66
|
+
|
|
67
|
+
**Usage:**
|
|
68
|
+
|
|
69
|
+
```python notest
|
|
70
|
+
import modal
|
|
71
|
+
from flask import Flask
|
|
72
|
+
|
|
73
|
+
app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
|
|
74
|
+
flask_app = Flask(__name__)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@flask_app.route("/")
|
|
78
|
+
def hello_world():
|
|
79
|
+
return "Hello, World!"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.function()
|
|
83
|
+
def run_app():
|
|
84
|
+
# Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
|
|
85
|
+
# expose that port to the world at a random HTTPS URL.
|
|
86
|
+
with modal.forward(8000) as tunnel:
|
|
87
|
+
print("Server listening at", tunnel.url)
|
|
88
|
+
flask_app.run("0.0.0.0", 8000)
|
|
89
|
+
|
|
90
|
+
# When the context manager exits, the port is no longer exposed.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Raw TCP usage:**
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import socket
|
|
97
|
+
import threading
|
|
98
|
+
|
|
99
|
+
import modal
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def run_echo_server(port: int):
|
|
103
|
+
"""Run a TCP echo server listening on the given port."""
|
|
104
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
105
|
+
sock.bind(("0.0.0.0", port))
|
|
106
|
+
sock.listen(1)
|
|
107
|
+
|
|
108
|
+
while True:
|
|
109
|
+
conn, addr = sock.accept()
|
|
110
|
+
print("Connection from:", addr)
|
|
111
|
+
|
|
112
|
+
# Start a new thread to handle the connection
|
|
113
|
+
def handle(conn):
|
|
114
|
+
with conn:
|
|
115
|
+
while True:
|
|
116
|
+
data = conn.recv(1024)
|
|
117
|
+
if not data:
|
|
118
|
+
break
|
|
119
|
+
conn.sendall(data)
|
|
120
|
+
|
|
121
|
+
threading.Thread(target=handle, args=(conn,)).start()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
app = modal.App()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@app.function()
|
|
128
|
+
def tcp_tunnel():
|
|
129
|
+
# This exposes port 8000 to public Internet traffic over TCP.
|
|
130
|
+
with modal.forward(8000, unencrypted=True) as tunnel:
|
|
131
|
+
# You can connect to this TCP socket from outside the container, for example, using `nc`:
|
|
132
|
+
# nc <HOST> <PORT>
|
|
133
|
+
print("TCP tunnel listening at:", tunnel.tcp_socket)
|
|
134
|
+
run_echo_server(8000)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**SSH example:**
|
|
138
|
+
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
|
|
139
|
+
letting you SSH into a Modal container.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
import subprocess
|
|
143
|
+
import time
|
|
144
|
+
|
|
145
|
+
import modal
|
|
146
|
+
|
|
147
|
+
app = modal.App()
|
|
148
|
+
image = (
|
|
149
|
+
modal.Image.debian_slim()
|
|
150
|
+
.apt_install("openssh-server")
|
|
151
|
+
.run_commands("mkdir /run/sshd")
|
|
152
|
+
.add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@app.function(image=image, timeout=3600)
|
|
157
|
+
def some_function():
|
|
158
|
+
subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
|
|
159
|
+
with modal.forward(port=22, unencrypted=True) as tunnel:
|
|
160
|
+
hostname, port = tunnel.tcp_socket
|
|
161
|
+
connection_cmd = f'ssh -p {port} root@{hostname}'
|
|
162
|
+
print(f"ssh into container using: {connection_cmd}")
|
|
163
|
+
time.sleep(3600) # keep alive for 1 hour or until killed
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If you intend to use this more generally, a suggestion is to put the subprocess and port
|
|
167
|
+
forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
|
|
168
|
+
ssh server and port for each container (and not one for each input to the function).
|
|
169
|
+
'''
|
|
170
|
+
...
|
|
28
171
|
|
|
29
172
|
class __forward_spec(typing_extensions.Protocol):
|
|
30
173
|
def __call__(
|
|
31
174
|
self, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
|
|
32
|
-
) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]:
|
|
175
|
+
) -> synchronicity.combined_types.AsyncAndBlockingContextManager[Tunnel]:
|
|
176
|
+
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
177
|
+
|
|
178
|
+
If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
|
|
179
|
+
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
180
|
+
make sure you are using a secure protocol over TCP.
|
|
181
|
+
|
|
182
|
+
**Important:** This is an experimental API which may change in the future.
|
|
183
|
+
|
|
184
|
+
**Usage:**
|
|
185
|
+
|
|
186
|
+
```python notest
|
|
187
|
+
import modal
|
|
188
|
+
from flask import Flask
|
|
189
|
+
|
|
190
|
+
app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
|
|
191
|
+
flask_app = Flask(__name__)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@flask_app.route("/")
|
|
195
|
+
def hello_world():
|
|
196
|
+
return "Hello, World!"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.function()
|
|
200
|
+
def run_app():
|
|
201
|
+
# Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
|
|
202
|
+
# expose that port to the world at a random HTTPS URL.
|
|
203
|
+
with modal.forward(8000) as tunnel:
|
|
204
|
+
print("Server listening at", tunnel.url)
|
|
205
|
+
flask_app.run("0.0.0.0", 8000)
|
|
206
|
+
|
|
207
|
+
# When the context manager exits, the port is no longer exposed.
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Raw TCP usage:**
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
import socket
|
|
214
|
+
import threading
|
|
215
|
+
|
|
216
|
+
import modal
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def run_echo_server(port: int):
|
|
220
|
+
"""Run a TCP echo server listening on the given port."""
|
|
221
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
222
|
+
sock.bind(("0.0.0.0", port))
|
|
223
|
+
sock.listen(1)
|
|
224
|
+
|
|
225
|
+
while True:
|
|
226
|
+
conn, addr = sock.accept()
|
|
227
|
+
print("Connection from:", addr)
|
|
228
|
+
|
|
229
|
+
# Start a new thread to handle the connection
|
|
230
|
+
def handle(conn):
|
|
231
|
+
with conn:
|
|
232
|
+
while True:
|
|
233
|
+
data = conn.recv(1024)
|
|
234
|
+
if not data:
|
|
235
|
+
break
|
|
236
|
+
conn.sendall(data)
|
|
237
|
+
|
|
238
|
+
threading.Thread(target=handle, args=(conn,)).start()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
app = modal.App()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.function()
|
|
245
|
+
def tcp_tunnel():
|
|
246
|
+
# This exposes port 8000 to public Internet traffic over TCP.
|
|
247
|
+
with modal.forward(8000, unencrypted=True) as tunnel:
|
|
248
|
+
# You can connect to this TCP socket from outside the container, for example, using `nc`:
|
|
249
|
+
# nc <HOST> <PORT>
|
|
250
|
+
print("TCP tunnel listening at:", tunnel.tcp_socket)
|
|
251
|
+
run_echo_server(8000)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**SSH example:**
|
|
255
|
+
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
|
|
256
|
+
letting you SSH into a Modal container.
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
import subprocess
|
|
260
|
+
import time
|
|
261
|
+
|
|
262
|
+
import modal
|
|
263
|
+
|
|
264
|
+
app = modal.App()
|
|
265
|
+
image = (
|
|
266
|
+
modal.Image.debian_slim()
|
|
267
|
+
.apt_install("openssh-server")
|
|
268
|
+
.run_commands("mkdir /run/sshd")
|
|
269
|
+
.add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@app.function(image=image, timeout=3600)
|
|
274
|
+
def some_function():
|
|
275
|
+
subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
|
|
276
|
+
with modal.forward(port=22, unencrypted=True) as tunnel:
|
|
277
|
+
hostname, port = tunnel.tcp_socket
|
|
278
|
+
connection_cmd = f'ssh -p {port} root@{hostname}'
|
|
279
|
+
print(f"ssh into container using: {connection_cmd}")
|
|
280
|
+
time.sleep(3600) # keep alive for 1 hour or until killed
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
If you intend to use this more generally, a suggestion is to put the subprocess and port
|
|
284
|
+
forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
|
|
285
|
+
ssh server and port for each container (and not one for each input to the function).
|
|
286
|
+
'''
|
|
287
|
+
...
|
|
288
|
+
|
|
33
289
|
def aio(
|
|
34
290
|
self, /, port: int, *, unencrypted: bool = False, client: typing.Optional[modal.client.Client] = None
|
|
35
|
-
) -> typing.AsyncContextManager[Tunnel]:
|
|
291
|
+
) -> typing.AsyncContextManager[Tunnel]:
|
|
292
|
+
'''Expose a port publicly from inside a running Modal container, with TLS.
|
|
293
|
+
|
|
294
|
+
If `unencrypted` is set, this also exposes the TCP socket without encryption on a random port
|
|
295
|
+
number. This can be used to SSH into a container (see example below). Note that it is on the public Internet, so
|
|
296
|
+
make sure you are using a secure protocol over TCP.
|
|
297
|
+
|
|
298
|
+
**Important:** This is an experimental API which may change in the future.
|
|
299
|
+
|
|
300
|
+
**Usage:**
|
|
301
|
+
|
|
302
|
+
```python notest
|
|
303
|
+
import modal
|
|
304
|
+
from flask import Flask
|
|
305
|
+
|
|
306
|
+
app = modal.App(image=modal.Image.debian_slim().pip_install("Flask"))
|
|
307
|
+
flask_app = Flask(__name__)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@flask_app.route("/")
|
|
311
|
+
def hello_world():
|
|
312
|
+
return "Hello, World!"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.function()
|
|
316
|
+
def run_app():
|
|
317
|
+
# Start a web server inside the container at port 8000. `modal.forward(8000)` lets us
|
|
318
|
+
# expose that port to the world at a random HTTPS URL.
|
|
319
|
+
with modal.forward(8000) as tunnel:
|
|
320
|
+
print("Server listening at", tunnel.url)
|
|
321
|
+
flask_app.run("0.0.0.0", 8000)
|
|
322
|
+
|
|
323
|
+
# When the context manager exits, the port is no longer exposed.
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**Raw TCP usage:**
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
import socket
|
|
330
|
+
import threading
|
|
331
|
+
|
|
332
|
+
import modal
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def run_echo_server(port: int):
|
|
336
|
+
"""Run a TCP echo server listening on the given port."""
|
|
337
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
338
|
+
sock.bind(("0.0.0.0", port))
|
|
339
|
+
sock.listen(1)
|
|
340
|
+
|
|
341
|
+
while True:
|
|
342
|
+
conn, addr = sock.accept()
|
|
343
|
+
print("Connection from:", addr)
|
|
344
|
+
|
|
345
|
+
# Start a new thread to handle the connection
|
|
346
|
+
def handle(conn):
|
|
347
|
+
with conn:
|
|
348
|
+
while True:
|
|
349
|
+
data = conn.recv(1024)
|
|
350
|
+
if not data:
|
|
351
|
+
break
|
|
352
|
+
conn.sendall(data)
|
|
353
|
+
|
|
354
|
+
threading.Thread(target=handle, args=(conn,)).start()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
app = modal.App()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@app.function()
|
|
361
|
+
def tcp_tunnel():
|
|
362
|
+
# This exposes port 8000 to public Internet traffic over TCP.
|
|
363
|
+
with modal.forward(8000, unencrypted=True) as tunnel:
|
|
364
|
+
# You can connect to this TCP socket from outside the container, for example, using `nc`:
|
|
365
|
+
# nc <HOST> <PORT>
|
|
366
|
+
print("TCP tunnel listening at:", tunnel.tcp_socket)
|
|
367
|
+
run_echo_server(8000)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
**SSH example:**
|
|
371
|
+
This assumes you have a rsa keypair in `~/.ssh/id_rsa{.pub}`, this is a bare-bones example
|
|
372
|
+
letting you SSH into a Modal container.
|
|
373
|
+
|
|
374
|
+
```python
|
|
375
|
+
import subprocess
|
|
376
|
+
import time
|
|
377
|
+
|
|
378
|
+
import modal
|
|
379
|
+
|
|
380
|
+
app = modal.App()
|
|
381
|
+
image = (
|
|
382
|
+
modal.Image.debian_slim()
|
|
383
|
+
.apt_install("openssh-server")
|
|
384
|
+
.run_commands("mkdir /run/sshd")
|
|
385
|
+
.add_local_file("~/.ssh/id_rsa.pub", "/root/.ssh/authorized_keys", copy=True)
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@app.function(image=image, timeout=3600)
|
|
390
|
+
def some_function():
|
|
391
|
+
subprocess.Popen(["/usr/sbin/sshd", "-D", "-e"])
|
|
392
|
+
with modal.forward(port=22, unencrypted=True) as tunnel:
|
|
393
|
+
hostname, port = tunnel.tcp_socket
|
|
394
|
+
connection_cmd = f'ssh -p {port} root@{hostname}'
|
|
395
|
+
print(f"ssh into container using: {connection_cmd}")
|
|
396
|
+
time.sleep(3600) # keep alive for 1 hour or until killed
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
If you intend to use this more generally, a suggestion is to put the subprocess and port
|
|
400
|
+
forwarding code in an `@enter` lifecycle method of an @app.cls, to only make a single
|
|
401
|
+
ssh server and port for each container (and not one for each input to the function).
|
|
402
|
+
'''
|
|
403
|
+
...
|
|
36
404
|
|
|
37
405
|
forward: __forward_spec
|